12  Control Flow

Control the flow of a computer program

12.1 Introduction

12.1.1 Overview

When do you need to control the flow of a program?

  • change outcome based on state of object[s] (condition[s])
    • conditional blocks: if...[elif]....[else], match...case
  • repeat certain task[s] or iterate through a collection of data
    • loops: for...[else], while...[else] blocks
  • avoid stopping of program on errors and handle the flow differently
    • error handlers: try...[except]...[else]...[finally] blocks
  • remove dependency on certain tasks
    • asyncio (asynchronous input output)
  • increase efficiency by optimizing redirection of tasks to multiple cpu cores/threads
    • parallel computing, …

Note: square brackets are generally used to represent something optional

As a beginner to programming conditional blocks and loops are sufficient to get started.

Error handlers are not needed for most of the basic stuff, but there are a few cases where it is useful to know the basics. For example it is useful to know the basics of using try block for a few situations. Most of debugging is based on the concepts related to error handlers. Editors provide support for debugging using these concepts. Developers rely heavily on using these concepts to handle expected errors differently. Therefore it is useful to understand the basics of error handling as it is certain to make errors and handle them.

Asynchronous input/output (asyncio) and parallel computing are advanced topics used to increase efficiency of larger programs. They are not covered.

12.1.2 Objectives

For all of the control flow techniques, conditionals, loops and error handlers

  • get acquainted to specifications
  • understand the rules and few implications conceptually
  • examples of common use cases
  • understand conceptually when to use what
  • experiment with individual pieces

12.2 Conditional blocks

Conditional blocks use conditions to control and change the flow of a program. Conditions are created using boolean operations.

  • if...[elif]...[elif]...[else] blocks
    • covers everything needed from basic conditional blocks
  • match...case...[case_] blocks
    • special case where a variable has to be tested for different values
    • in most cases can be implemented using if block
    • better for code readability
    • has some additional special features related to pattern matching

12.2.1 if blocks

if blocks are used to execute some code only when a condition is evaluated to True.

12.2.1.1 Specifications

Below are possible forms of if block.

if condition:
    # if block content
if condition:
    # if block content
else:
    # else block content
if condition_1:
    # if block content
elif condition_2:
    # elif block 1 content
elif condition_3:
    # elif block 2 content
else:
    # else block content
# execute some short expression conditionally
if condition: <short expression>
  • start an if block
    • if statement with an expression ending in colon (:)
  • end an if block
    • short expression on the same line
    • indented block of one or more lines of code
  • there can be 0 or more elif (else if) blocks
  • there can be 0 or 1 else block
  • syntax of elif and else is same as if
12.2.1.1.1 Ternary operator

Python additionally provides a ternary form for short, one line conditionals. Below is the syntax, where X and Y are short expressions to be evaluated and returned.

Ternary operator is useful for code readability. It removes the requirement for a multiline if block for quick checks required in code.

X if condition else Y
print("condition True") if 1 > 0 else print("condition False")
>>>  condition True
print("condition True") if 1 == 0 else print("condition False")
>>>  condition False

12.2.1.2 Control flow

Flow diagram below illustrates the control flow for a if block.

Basic idea

  • check if and elif conditions sequentially
    • if any of the conditions evaluate to True
      • execute the corresponding block content and exit
    • if all of the conditions evaluate to False
      • execute else block if present and exit

12.2.1.3 Examples

12.2.1.3.1 Basic
some_num = 12
if some_num >= 0:
    print(f'{some_num} is positive')
>>>  12 is positive
12.2.1.3.2 With else block
some_num = 12
if some_num >= 0:
    print(f'{some_num} is positive')
else:
    print(f'{some_num} is negative')
>>>  12 is positive
12.2.1.3.3 With elif block
some_num = 12
if some_num == 0:
    print(f'{some_num} is zero')
elif some_num > 0:
    print(f'{some_num} is positive')
elif some_num < 0:
    print(f'{some_num} is negative')
>>>  12 is positive
12.2.1.3.4 With elif and else
some_num = 12
if some_num == 0:
    print(f'{some_num} is zero')
elif some_num > 0:
    print(f'{some_num} is positive')
else:
    print(f'{some_num} is negative')
>>>  12 is positive

12.2.2 match...case block

Match blocks are used if some object is to be tested against multiple cases. It can be achieved using if blocks but match blocks are better for code readability and ease of use for the given use case.

_ is optional and for case when none of the options match.

match some_obj:
    case option_1:
        # do something and exit
    case option_2:
        # do something and exit
    [case _:
        # do something and exit
    ]

 

if some_obj == option_1:
    # do something and exit
elif some_obj == option_2:
    # do something and exit
[else
    # do something and exit
]

More information can be found at Python documentation for match statements.

12.3 Loops

  • Loops are needed for iteration
    • repeating certain pieces of code
    • iterating over collections to access, operate or modify elements
  • When number of repetitions is
    • known: use for block
    • not known: use while block
  • It is recommended not to modify the collection that is being iterated,
    instead use any of the below solutions
    • create a new collection
    • create a copy

Iterating is a common term which refers to going through elements of a collection. Iterables and iterators in Python are based on this idea.

12.3.1 for block

12.3.1.1 Specifications

for item in iterable:
    # do something
    # item is available
    print(item)
for i in range(n):
    # do something
    # i is available
    print(item)

 

for item in iterable: print(item)

Fundamental form

  • for keyword declares the start of a for block
  • item in iterable is the generic form
  • :, colon, to declare end of for declaration
  • code to be repeated, which has access to an item for a given iteration
    • if a short expression has to be repeated it can be used on the same line

Using this fundamental form other variation are created. e.g.

  • range(n) function is used to loop through fixed number of times
    • example to repeat 10 times
      • for i in range(10): i takes values 0 through 9
      • for i in range(1, 11): i takes values 1 through 10
    • range() function is discussed at Section 11.7
  • loops can be nested using indentation
    • useful for working with nested data structures
12.3.1.1.1 continue

continue clause causes the loop to jump to next iteration without executing remaining lines in loop.

In the below structure, when the condition is true, item will not be printed.

for item in iterable:
    # do something
    # item is available
    if condition:
        continue
    print(item)

Continue is useful if you need to skip execution of some code for certain elements of the iterable.

12.3.1.1.2 break and else
  • break clause causes exit of the innermost loop
    • is optional
    • innermost is critical when there are nested loops
  • else clause
    • is optional
    • is executed only if loop ends normally, i.e. no break is hit
for item in iterable:
    # do something
    # item is available
    if condition:
        break
    print(item)
else:
    print("No break found")

12.3.1.2 Control flow

12.3.1.3 Examples

12.3.1.3.1 Basic
  • Loop through 1 to n_max = 20
    • store even numbers in a list evens
    • store odd numbers in a list odds
n_max = 20
evens = []; odds = []
for i in range(1, n_max + 1):
    evens.append(i) if i % 2 == 0 else odds.append(i)
print(evens)
>>>  [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
print(odds)
>>>  [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
12.3.1.3.2 continue

In the below example only odd numbers are printed as the loop hits continue for even numbers.

for i in range(5):
    if i % 2 == 0: continue
    print(i)
>>>  1
>>>  3
12.3.1.3.3 break and else
  • For a given list of numbers
    • validate if the numbers are within some limits, e.g. [0, 100]
    • if all values are within limits
      • print “validation successful”
    • if any of the values are outside limits
      • print “validation failed” with value
      • exit the loop
correct_list = [65, 24, 53, 91, 59, 81, 93, 7, 78, 10]
for num in correct_list:
    if num < 0 or num > 100:
        print(f"validation failed, list contains {num}")
        break
else:
    print("validation successful")
>>>  validation successful
incorrect_list = [97, 144, 115, 127, 33, 99, 85, 109, 21, 110]
for num in incorrect_list:
    if 0 <= num <= 100:
        continue
    else:
        print(f"validation failed, list contains {num}")
        break
else:
    print("validation successful")
>>>  validation failed, list contains 144

Notice that else block is useful in this situation as it is run only when for loop iteration is complete without hitting break statement.

There are different ways to structure the same conditions and required outcome.

12.3.1.3.4 Nested loops

Print table of numbers from 1 through 12 for multiplication with 1 through 10.

for i in range(1, 13):
    for j in range(1, 11):
        end = "\n" if j == 10 else "\t"
        print(i*j, end=end)
>>>  1  2   3   4   5   6   7   8   9   10
>>>  2  4   6   8   10  12  14  16  18  20
>>>  3  6   9   12  15  18  21  24  27  30
>>>  4  8   12  16  20  24  28  32  36  40
>>>  5  10  15  20  25  30  35  40  45  50
>>>  6  12  18  24  30  36  42  48  54  60
>>>  7  14  21  28  35  42  49  56  63  70
>>>  8  16  24  32  40  48  56  64  72  80
>>>  9  18  27  36  45  54  63  72  81  90
>>>  10 20  30  40  50  60  70  80  90  100
>>>  11 22  33  44  55  66  77  88  99  110
>>>  12 24  36  48  60  72  84  96  108 120

12.3.2 while

12.3.2.1 Specifications

while condition:
    # code block
    [continue]
    # code block
    [break]
[else]:
    # code block
  • loop repeats while condition is True or break statement is hit
  • continue, break, else clauses are optional

Below diagram illustrates the control flow for a while loop.

  • main while block contents are executed repeatedly until condition is True
    • if continue statement is hit
      • loop restarts and condition is checked again
  • if break statement is hit anywhere loop exits
    • without going through else block even if present
  • when condition is False
    • else block is executed if present
    • loop is exited

12.3.2.2 Use cases

A while loop is needed when number of repetitions is not known in advance

  • When data structure is dependent on external sources
    • e.g. user input, web data base, …
  • Recursive algorithms
    • Computer science: binary search, merge sort, etc.
    • Math:
      • numerical methods of approximations
      • algorithm to find greatest common divisor

12.3.2.3 Examples

12.3.2.3.1 Infinite loop
  • to be avoided
  • very easy to create by not modifying the condition in while block
  • remember ctrl + c to bail out
condition = True
while condition:
    # forget to set condition = False
    print("inside infinite loop")
12.3.2.3.2 Basic
a = 6
while a != 0:
    a -= 1
    if a == 2:
        print(f'break block: {a = }')
        break
    if a == 3:
        print(f'continue block: {a = }')
        continue
    print(f'main block: {a = }')
>>>  main block: a = 5
>>>  main block: a = 4
>>>  continue block: a = 3
>>>  break block: a = 2
12.3.2.3.3 GCD algorithm

Below is gcd algorithm to find the greatest common divisor of 2 integers using while loop.

  • given 2 numbers a, b
    1. find remainder of a, b (modulo operator % gives the remainder)
    2. if remainder is zero then b is the gcd
    3. replace a with b and b with remainder (in Python this is 1 step using multiple assignment)
    4. goto to step 1

Since the number of repetitions needed are based on input, while loop is suitable for this.

a, b = 9, 6
rem = a % b
while rem != 0:
    print(f'{a = }, {b = }, {rem = }')
    a, b = b, rem
    rem = a % b

print(f'{a = }, {b = }, {rem = }')
print(b)
>>>  a = 9, b = 6, rem = 3
>>>  a = 6, b = 3, rem = 0
>>>  3

12.3.3 Looping techniques

12.3.3.1 Iterators and Iterables

Iterator is an object that can be iterated upon its elements only once. It is exhaustible.

Iterable is an object that can be iterated upon its elements repeatedly.

Both cannot be viewed directly and have to be converted into a list or tuple to view contents.

Caution

Be careful while using iterators since they are consumable or exhaustible.

Do not store them in variables for re-use.

some_tuple = "a", "b", "c", "d"
some_iterator = enumerate(some_tuple)
print(list(some_iterator))
>>>  [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd')]
print(list(some_iterator))
>>>  []
Python functions and methods that return iterators and iterables
Iterators Iterables
zip range
enumerate dictionary.keys
open dictionary.values
dictionary.items

12.3.3.2 Accessing index of iterables (sequences)

It is very common situation where both index and value is needed while iterating over a sequence (sequence is also an iterable).

Python provides a special function, enumerate(iterable) to access index and value of items in an iterable.

some_tuple = "a", "b", "c", "d"
list(enumerate(some_tuple))
>>>  [(0, 'a'), (1, 'b'), (2, 'c'), (3, 'd')]
for idx, item in enumerate(some_tuple):
    print(f'{idx=}, {item=}')
>>>  idx=0, item='a'
>>>  idx=1, item='b'
>>>  idx=2, item='c'
>>>  idx=3, item='d'

It is possible to have access to index without enumerate function, which is lengthier, inconvenient and less efficient, hence not required while using Python.

for idx in range(len(some_tuple)):
    print(f'{idx=}, item={some_tuple[idx]}')
>>>  idx=0, item=a
>>>  idx=1, item=b
>>>  idx=2, item=c
>>>  idx=3, item=d

12.3.3.3 Multiple iterables

Python provides a zip(iterable1, iterable2, ...) function which returns an iterator with tuples of elements of iterables provided until the shortest iterable is exhausted.

This is helpful when multiple iterables are needed to be iterated through in some connected way.

some_list = [1, 2, 3]
some_tuple = (4, 5, 6)
list(zip(some_list, some_tuple))
>>>  [(1, 4), (2, 5), (3, 6)]
result = []
for e1, e2 in zip(some_list, some_tuple):
    result.append(e1 + e2)
print(result)
>>>  [5, 7, 9]

This can be achieved without zip but again since it is lengthier and inconvenient, it is not recommended while using Python.

result = []
for idx in range(len(some_list)):
    result.append(some_tuple[idx] + some_list[idx])
print(result)
>>>  [5, 7, 9]

12.3.3.4 Dictionary

Python provides multiple functions to help iterating over dictionaries.

  • keys: d.keys()
  • values: d.values()
  • keys, values: d.items()

All of these return a view object which is an iterator. Most commonly used is d.items() as it provides access to both key and value.

some_dict = {"key 1": "value 1", "key 2": "value 2", "key 3": "value 3"}
for k, v in some_dict.items():
    print(f'key = {k}, value = {v}')
>>>  key = key 1, value = value 1
>>>  key = key 2, value = value 2
>>>  key = key 3, value = value 3

12.3.3.5 Mutable collections

It is important to remember that while iterating through mutable collections (list, dict, set) it is recommended not to modify the collection as it gets complicated to handle unexpected situations. So best to use the solutions provided when in doubt rather than introducing a bug.

If the collection is immutable then modifying is not allowed and generally not used.

Another thing to note is modifying the collection in this context refers to changing the structure of collection, e.g. deleting an item. It does not refer to modifying the value of an item within the collection.

Solutions to this are, if a mutable collection is to be modified then

  • create a new collection
  • create a copy while iterating
12.3.3.5.1 Examples with errors

Below are some examples to illustrate when things go wrong when modifying a mutable collection while iterating through it.

12.3.3.5.1.1 List

In the list in example all numbers <= 3 were to be removed, but there is bug due to modifying the list while iterating over it.

Note that 2 is not removed. Figuring out in this case is simple. In the 2nd iteration 1 was already removed from the list and second item was 3 not 2.

This is a simple example hence finding the bug is easy, but as programs get larger it becomes difficult and inefficient to find such bugs.

nums = list(range(1, 6))
print(nums)
>>>  [1, 2, 3, 4, 5]
for num in nums:
    if num <= 3:
        nums.remove(num)

print(nums)
>>>  [2, 4, 5]
12.3.3.5.1.2 Dictionary

In case of dictionary it gives a run time error.

some_dict = {"k1": "v1", "k2": "v2", "k3": "v3"}
for k, v in some_dict.items():
    if k == "k2":
        del some_dict[k]
>>>  Error: RuntimeError: dictionary changed size during iteration
12.3.3.5.2 Solutions
12.3.3.5.2.1 Create a new collection

List

nums = list(range(1, 6))
new_nums = []
for num in nums:
    if not num <= 3:
        new_nums.append(num)
print(nums)
>>>  [1, 2, 3, 4, 5]
print(new_nums)
>>>  [4, 5]

Dictionary

some_dict = {"k1": "v1", "k2": "v2", "k3": "v3"}
new_dict = {}
for k, v in some_dict.items():
    if not k == "k2":
        new_dict[k] = v
print(some_dict)
>>>  {'k1': 'v1', 'k2': 'v2', 'k3': 'v3'}
print(new_dict)
>>>  {'k1': 'v1', 'k3': 'v3'}
12.3.3.5.2.2 Create a copy

List

nums = list(range(1, 6))
print(nums)
>>>  [1, 2, 3, 4, 5]
for num in nums.copy():
    if num <= 3:
        nums.remove(num)
print(nums)
>>>  [4, 5]

Dictionary

some_dict = {"k1": "v1", "k2": "v2", "k3": "v3"}
print(some_dict)
>>>  {'k1': 'v1', 'k2': 'v2', 'k3': 'v3'}
for k, v in some_dict.copy().items():
    if k == "k2":
        del some_dict[k]
print(some_dict)
>>>  {'k1': 'v1', 'k3': 'v3'}

12.4 Error handlers

12.4.1 Introduction

12.4.1.1 Errors

  • given the number of rules and syntax there are a lot of opportunities for errors

  • 2 broad categories

    • Syntax error: caught when the code is being parsed
      • parsed means interpreter is reading the code to figure out what is to be done
      • commonly referred to as compile time error
    • Exception: error detected during execution
      • commonly referred to as run time error
  • Syntax error

a =, 2
>>>  invalid syntax (<string>, line 1)
  • Exception
# try:
#     1/0
# except Exception as e:
#     print(e)
eg_num = 1/0
>>>  Error: ZeroDivisionError: division by zero

When a run time error occurs, program stops and exception messages are printed along with the traceback. Traceback is a track of where the error occurred in the program to which part of the program lead to running of the line which caused the error.

12.4.1.2 Exceptions

Python has some built-in known exceptions or error types. Exceptions are objects built using object oriented programming.

  • Examples
    • Exception: base catch almost all errors
    • ZeroDivisionError: division by zero
    • TypeError: operation not supported by type[s] used in operation
  • On run time error, an Exception type object is created which has information regarding
    • Exception type
    • traceback

Below figure shows Python’s built-in Exception hierarchy. More details can be found at Python documentation for built in exceptions.

12.4.1.2.1 raise

raise statements causes the program to stop with default exception. Optionally a known error can be passed with a custom message, e.g. raise ValueError("Invalid input"). raise statement can be used anywhere in the program but is suited for use with try and while blocks, when an expected error is intercepted and has to handled differently.

raise ValueError("some custom message")

12.4.1.3 Examples

Below are some examples of common errors which are recommended to be tried and experimented in jupyter notebook cells. See the error messages and map them to the Exception hierarchy. Look at the traceback to see how it is structured.

1/0
>>>  Error: ZeroDivisionError: division by zero
undefined_var_name
>>>  Error: NameError: name 'undefined_var_name' is not defined
some_list = [1, 2, 3]
print(some_list[10])
>>>  Error: IndexError: list index out of range
float("text")
>>>  Error: ValueError: could not convert string to float: 'text'
"abc"*"xyz"
>>>  Error: TypeError: can't multiply sequence by non-int of type 'str'

12.4.2 try blocks

try...except blocks are used to intercept and handle expected run time errors differently. It provides many options like

  • ignore the error and continue running the program
  • do some clean up before letting the error stop the program
  • run some code irrespective of error or not

Note that exception handling has a large number of specifications which can be combined in a lot of ways which can lead to complexity in the beginning. This is being covered with the objective of simple usage. All the specifications are for completeness and information.

12.4.2.1 Specifications

try...[except]...[else]...[finally]

try:
    # try code block
[except Exception as e:
    # except code block
    [raise]]
[else:
    # else code block]
[finally:
    #  finally code block]
  • required
    • there must be at-least one except or finally block
    • else clause (if present) should be after the except block
  • optional
    • there can be multiple except clauses
    • raise statement and else block are optional
  • try block statements are always evaluated first
  • except blocks are run when they trap specified error
    • if raise statement is hit
      • finally block is run
      • program is stopped with the <Exception type> raised
  • else block is run in case of no error
  • finally block is run always, error or no error
    • generally used for clean up

12.4.2.1.1 except blocks

except statement in a try block is used to declare an except block. They can be used in 2 forms.

  • except <Exception type>
  • except <Exception type> as <variable name>

except block is run if there is a run time error while executing try block and the error matches the <Exception type> specified.

When second form is used, <variable name> is bound to the <Exception type> object.

There are 2 concepts involved.

  • which Exception is supposed to be handled, e.g. NameError, TypeError, etc.
  • error object

The first concept is of using the exception type. Base Exception catches all known errors and is the most generic. In the beginning it should be enough. As the requirements increase there is a need to specify more specific errors. For multiple except blocks, it is recommended to have specific exceptions before generic exceptions

The second is the error object. In the beginning it is not of much use as handling the exception should be enough, but as you progress it is this object which helps in dealing different errors differently as it holds a lot of information, like the traceback.

12.4.2.2 Use cases

Try blocks are generally used to intercept expected errors and control the outcome if error occurs, use case depends on the context. e.g. 

  • avoid stopping program on an expected error
    • when program depends on external sources, e.g. db operations, web requests, …
  • do something on an expected error and then raise the error
  • handle different error types differently

12.4.2.3 Examples

12.4.2.3.1 Ask user input until it is correct

This is an example for nesting different control flow techniques.

The same problem serves as a use case for using recursive functions which is discussed in Section 13.9.1.3.

while True:
    try:
        some_int = int(input('Enter an integer...'))
        print(some_int)
        break
    except ValueError:
        continue