4 Control Flow

Bottom line up front, Python is weird about groupings. Instead of using braces and making things clear and explicit, Python enforces indentation as a way of grouping code. What this means if you have both spaces and tabs is unclear, but it’s certainly going to get confusing. Add to this Python’s mixed bag when it comes to putting function arguments in brackets and we’ve got a real mess.

4.1 If statements

The general syntax for an if statement is:

if condition :
    execute this
    and this because we're still indented
execute this regardless because we've got no indent

The general syntax for an if else statement is:

if condition :
    execute this
else :
    execute this

The general syntax for an else if statement is:

if condition :
    execute this
elif condition :
    execute this
else :
    execute this

4.2 While loop

The general syntax for a while loop is:

while condition :
    expression

4.3 For loop

The general syntax for a for loop is:

for var in seq :
    expression

You can also do inline enumeration if you want to (to get access to the index as well as the value), but it’s a bit flimsy and hard to understand because it only exists in the for loop syntax (so you can’t easily debug how it works):

for index, var in enumerate(seq):
    expression

You can also iterate along multiple columns in nested lists:

for room, area in house :
    expression

To iterate through the rows of a pandas DataFrame, you can use the following:

for lab, row in df.iterrows() :
    print(lab) # The row label
    print(row) # The pandas Series, i.e. a named list consisting of the row elements

To iterate through key:value pairs in a dictionary, use:

for key, val in dict.items() :
  print(key) # The key
  print(val) # The value

4.4 Error Handling

Error handling in Python seems to be based on the try-except model. You can use this to control execution flow conditional on the type of error raised, and you can raise your own errors using the raise keyword.

def sqrt(x) :
    """Returns the square root of a number."""
    try:
        if x < 0 :
            raise ValueError('x must be non-negative')
        return x ** 0.5
    except TypeError:   # This section will execute if there is a TypeError
        print('x must be an int or a float')
        
print(sqrt(4))
## 2.0
print(sqrt('4'))
## x must be an int or a float
## None
print(sqrt(-4))
## ValueError: x must be non-negative
## 
## Detailed traceback: 
##   File "<string>", line 1, in <module>
##   File "<string>", line 5, in sqrt

Amongst all the printout, you can see that this has indeed raised a ValueError with the error message specified.

4.5 Iterators and Iterables

  • Examples include lists, strings, dictionaries, file connections
  • They need to have an iter() method
  • Applying iter() to an iterable creates an iterator
  • Iterators need to have a next() method
word = "Dog"
it = iter(word)
print(next(it))
## D
print(next(it))
## o
print(next(it))
## g
print(next(it))
## StopIteration: 
## 
## Detailed traceback: 
##   File "<string>", line 1, in <module>

You can also use the “splat” operator to get all remaining elements.

word = "Hello"
it = iter(word)
print(next(it))
## H
print(*it)
## e l l o

4.5.1 Enumerate

The enumerate() function can be used to return an iterator which gives two values, the index and the value of the underlying iterable.

words = ["Hello", "World", "!!!"]
for i, x in enumerate(words) :
  print(i, x)
## 0 Hello
## 1 World
## 2 !!!

4.5.2 Zip

The zip() function can be used to take two or more lists and “zip” them together into a single iterable.

list1 = [0,1,2,3]; list2 = [10,11,12,13]; list3 = [20,21,22,23]
for a, b, c in zip(list1, list2, list3) :
  print(a, b, c)
## 0 10 20
## 1 11 21
## 2 12 22
## 3 13 23

You can also use the zip() function to unzip, by using the splat operator.

tuple1 = (0,1,2,3); tuple2 = (10,11,12,13);
z = zip(tuple1, tuple2)
result1, result2 = zip(*z)
print(result1 == tuple1)
## True
print(result2 == tuple2)
## True

4.6 List Comprehensions

List comprehensions collapse for loops for building lists into a single line. You can write a list comprehension over any iterable. The general structure is:

<result> = [<output expression> for <iterator variable> in <iterable>]

Some examples:

nums = [12, 8, 21, 3, 16]
new_nums = [num + 1 for num in nums]
print(new_nums)
## [13, 9, 22, 4, 17]
result = [num for num in range(11)]
print(result)
## [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

You can also use nested comprehensions by chaining multiple for statements together, but it starts to get a bit ugly.

pairs = [(num1, num2) for num1 in range (0,2) for num2 in range (6,8)]
print(pairs)
## [(0, 6), (0, 7), (1, 6), (1, 7)]

You can also use conditionals within list comprehensions. The general syntax is:

<result> = [<output> for <ivar> in <iterable> if <condition>]

For example, to make a list of the squares of only even numbers, you could use:

print([num ** 2 for num in range(10) if num % 2 == 0])
## [0, 4, 16, 36, 64]

You can also use conditionals on the output expression, with the general syntax

<result> = [<true output> if <condition> else <false output> for <ivar> in <iterable>]

For example, to square even numbers but not square odd numbers, you could use:

print([num ** 2 if num % 2 == 0 else num for num in range(10)])
## [0, 1, 4, 3, 16, 5, 36, 7, 64, 9]

You can also create dictionary comprehensions using the same syntax.

# Create a list of strings: fellowship
fellowship = ['frodo', 'samwise', 'merry', 'aragorn', 'legolas', 'boromir', 'gimli']
# Create dict comprehension: new_fellowship
new_fellowship = {member:len(member) for member in fellowship}
# Print the new list
print(new_fellowship)
## {'frodo': 5, 'samwise': 7, 'merry': 5, 'aragorn': 7, 'legolas': 7, 'boromir': 7, 'gimli': 5}

4.7 Generators

A generator is like a list comprehension, except that it does not store the list in memory. You can create a generator by replacing [] with () in any list comprehension statement. It behaves like any other iterator, except that the value is lazily evaluated - i.e. the elements of the sequence are only calculated at the time they’re needed.

As a trivial example of why this could be useful, the following code will cause my Python session to crash:

[num for num in range(10**1000000)]

But I can comfortably create a generator which I can use as an iterable:

it = (num for num in range(10**1000000))
print(it)
## <generator object <genexpr> at 0x123aff518>

You can also write generator functions which can include any arbitrary code. The only difference between normal functions and generator functions is that instead of using return to return all objects at once, a generator function will use yield multiple times throughout the execution of the function. When the generator function is iterated over, the function will run through the code until it hits yield, then it will return that value to the called (which most likely called next() on the iterator), and then pause execution. The next time next() is called, it will yield the next value, and so on until the generator function is exhausted (when the function finishes).

def num_sequence(n) :
  i = 0
  while i < n :
    yield i
    i += 1
it = num_sequence(3)
print(next(it))
## 0
print(next(it))
## 1
print(next(it))
## 2
for num in num_sequence(3) :
  print(num)
## 0
## 1
## 2