Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Program Flow

Here we want to introduce the basic concepts of program flow that occur in almost every programming language.
To implement a logical program flow, we need conditional statements and loops.
Furthermore, we will learn how to implement our own functions to better structure the program flow and reuse frequently used code.

Logical Operators

Logical operators are used in if statements and loops to control the program flow:

OperatorMeaning
a==ba and b are equal
a < ba is less than banalogous to >
a <= ba is less than or equal to banalogous to >=
a != ba is not equal to b

Comparison operations always return an object of type bool, i.e., either True or False:

b = 1<2  ##en
print("The result of 1<2 is of type", type(b), "and has the value", b)
The result of 1<2 is of type <class 'bool'> and has the value True

Comparison operators can only be used if these operations are defined for the data types being compared. The following comparisons work:

# int-int  #en
print("1 is less than 3:", 1<3)

# string-string (compare alphabetical order)
print("'abc' is less than 'bcd':", 'abc'<'bcd')

# int-float
print("2 is greater than or equal to 2.0:", 2 >= 2.0)
1 is less than 3: True
'abc' is less than 'bcd': True
2 is greater than or equal to 2.0: True

For complex numbers, there is no comparison operation such as < or >.

(1+3j)<(2-4j)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[3], line 1
----> 1 (1+3j)<(2-4j)

TypeError: '<' not supported between instances of 'complex' and 'complex'

Boolean expressions can also be combined. The equivalents of the mathematical expressions aba\wedge b, aba\vee b, and ¬a\neg a are:

OperatorMeaning
a and breturns True if both a and b are True
a or breturns True if either a or b is True
not areturns True if a is False

Example: We want to test whether a number xx lies in the interval [1/4,3/4)[1/4,3/4):

import random

# Generate a random number between 0 and 1
x = random.random()

# Test whether x lies in the interval [1/4, 3/4):
in_interval = x >= 1/4 and x < 3/4

# Print to console
print("x =", x)
print("x lies in [1/4, 3/4):", in_interval)
x = 0.38517796941543214
x lies in [1/4, 3/4): True

Conditional Statements (if-statements)

Conditional statements in Python can be implemented as follows:

if <condition_1>:
    # If condition_1 is True:
    [do something]
    ...
    [do something more]
elif <condition_2>:
    # If condition_1 is False and condition_2 is True:
    [do something]
    ...
    [do something more]
else:
    # If both condition_1 and condition_2 are False:
    [do something]
    ...
    [do something more]

Of course, the statement can be extended with any number of elif blocks.
condition_1 and condition_2 must be Boolean variables, i.e., either True or False. The indentation of the code under the if statement determines what actions are executed when condition_1 is True. The code block that is executed ends with the last indented line. See the output of the following code for illustration:

x = 0.8

if x < 0.5:
    print("I belong to the conditional block")
    print("Me too")
    
if x < 0.5:
    print("I belong to the conditional block")
print("Not me")  # Only this line will be executed
Not me
x = 0.2

if x < 0.5:
    print("I belong to the conditional block")
    print("Me too")
    
if x < 0.5:
    print("I belong to the conditional block")
print("Not me")
I belong to the conditional block
Me too
I belong to the conditional block
Not me

In the following example, a random number between 1 and 10 is generated and tested to see whether it is even or odd. We will also learn about the modulo operator %, which returns the remainder of the division of two integers. Example: 8%3 equals 2, since 8=23+28=2\cdot 3+\textbf{2}. This process is repeated 10 times. An explanation of for-loops follows in the next section.

for k in range(10):
    x = random.randint(1,11)
    if x % 2 == 0:
        print(x, "is an even number")
    else:
        print(x, "is an odd number")
7 is an odd number
7 is an odd number
8 is an even number
11 is an odd number
10 is an even number
11 is an odd number
8 is an even number
5 is an odd number
7 is an odd number
8 is an even number

Loops

for-loops

To understand the need for loops, let’s first consider the following code, which calculates the sum of numbers from a list:

a = [1,4,7,4,2]
value = 0

value += a[0]
value += a[1]
value += a[2]
value += a[3]
value += a[4]

print("The sum of the numbers", a, "is", value)
The sum of the numbers [1, 4, 7, 4, 2] is 18

Here, we obviously want to iterate over all elements of a list. This becomes problematic when the list contains several thousand elements. Our code would then grow to several thousand lines. Moreover, we have here relied on knowing the number of elements in the list. The lines value += a[i] only differ by the index i, and loops allow us to combine all these lines into a single structure.

The first type of loop we want to discuss here is the for loop. The general syntax is:

for <elem> in <iterable_object>:
    [do something]
    ...
    [do something more]
  • <iterable_object> can be any object that provides an iterator (more on this in section Iterators)

  • Examples: list, a tuple, or NumPy array (see section Linear Algebra with NumPy), string

The above example can be simplified using a for loop:

value = 0
for elem in a:
    value += elem
print("The sum of the numbers", a, "is", value)
The sum of the numbers [1, 4, 7, 4, 2] is 18

Here, the line value += elem is repeated as many times as the list contains elements. The variable elem takes on the values a[0], a[1], ..., a[4] sequentially.

In addition to the iterable data types mentioned above, there is the range class. A range object represents an interval for the iteration index with a start and end index. From the Python documentation (accessible via range?) we learn, for example:

range(stop) -> range object
range(start, stop[, step]) -> range object

Return an object that produces a sequence of integers from start (inclusive)
to stop (exclusive) by step.  range(i, j) produces i, i+1, i+2, ..., j-1.
start defaults to 0, and stop is omitted!

Here is a small test:

# 5 loop iterations
for i in range(5):
    print("Loop iteration", i)
Loop iteration 0
Loop iteration 1
Loop iteration 2
Loop iteration 3
Loop iteration 4
# 3 loop iterations, starting with iteration index 2
for i in range(2,5):
    print("Loop iteration", i)
Loop iteration 2
Loop iteration 3
Loop iteration 4

Note: If the number of loop iterations is known before the first iteration, a for loop should be used. Otherwise, use a while loop, which we will discuss in the following section.

Loops can be further controlled within the loop block using the continue and break statements. With break, an additional exit condition can be implemented. In the example, the number 5 is searched for in a list, and the loop is terminated upon the first occurrence of this number.

L = [1,4,5,7,9]
for x in L:
    if x == 5:
        print("List contains", 5)
        break
    else:
        print(x, "is not 5")
1 is not 5
4 is not 5
List contains 5

In the previous example, the loop is terminated with break after encountering the number 5 — it will not continue after the third iteration.

In contrast, continue only skips the current iteration and immediately moves to the next iteration.

Example: Calculate the sum of all even numbers from 1 to 10:

val = 0
for x in range(1,11):
    if x % 2 == 1:  # if x is odd
        continue
        
    val += x
print("2+4+...+10 =", val)
2+4+...+10 = 30

while-loops

In for loops, the termination condition was known before the first iteration.
If the termination condition changes during the loop, a while loop is used.
The general syntax is:

while <condition>:
    [do something]
    ...
    [do something more]
  • <condition> is a Boolean expression.

  • The indented loop block is executed as long as <condition> is True.

  • To avoid an infinite loop, it must be ensured that <condition> becomes False after a finite number of iterations.

In the following example, random numbers are generated until a number divisible by 5 is produced:

number = 1  # Initialize with 1 to satisfy the loop condition

while not number % 5 == 0:
    number = random.randint(1,100)
    print("Random number:", number)
    
print(number, "is our random number divisible by 5")
Random number: 90
90 is our random number divisible by 5

Even in while loops, the statements continue and break apply.

Functions

Defining Your Own Functions

Frequently reused parts of a program can be encapsulated in functions to improve code readability. A function must be defined before its first call.

The general syntax of a function definition is

def <function_name>(<param1>, <param2>[, ...]):
    [do something]
    ...
    [do something more]
    return <ret1>, <ret2>[, ...]

The parameters <param1>, <param2>, etc., are passed when the function is called.
The return values <ret1>, <ret2>, etc., are returned by the function and can, for example, be stored as a tuple.
If the function does not need to return any values, e.g., a function that only prints to the console, the return line can be omitted.

Note: When return is called, the function exits immediately. Any subsequent code inside the function will not be executed.

The syntax of a function call is

<ret1>, <ret2>[, ...] = <function_name>(<param1>, <param2>[, ...])

Let’s start with a simple example. The following function sums all elements of an iterable object L and returns the result:

def sum(L):
    val = 0
    for elem in L:
        val += elem
    return val

To execute this function, we write:

#en
L = [1,4,7]
sum_L = sum(L)
print("The sum of the numbers", L, "is", sum_L)

L.pop()
L.append(9)
L.append(13)

sum_L = sum(L)
print("The sum of the numbers", L, "is", sum_L)
The sum of the numbers [1, 4, 7] is 12
The sum of the numbers [1, 4, 9, 13] is 27

Note that we can call our sum function with parameters of any type for which all operations used in sum are defined.

Our sum function can therefore also be called with tuples:

sum((1,2,3))
6

Not with strings, however. Strings are also iterable, which allows their use in a for loop, but the += operation between an integer (note: val is an integer due to the line val = 0) and a string is not defined:

sum("Test")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[18], line 1
----> 1 sum("Test")

Cell In[15], line 4, in sum(L)
      2 val = 0
      3 for elem in L:
----> 4     val += elem
      5 return val

TypeError: unsupported operand type(s) for +=: 'int' and 'str'

Local and Global Variables

It should be noted that all variables defined inside a function are only local variables. They are therefore not visible outside the function.

If a global variable with the same name already exists, the function will work only with the local variable:

value = 1                    # global variable named 'value'

def get_successor(a):    
    value = a + 1            # local variable named 'value'
    return value

print("Value is", value)     # prints the global variable 'value'
print("The successor of 3 is", get_successor(3))
print("Value is", value)     # prints the global variable 'value'
Value is 1
The successor of 3 is 4
Value is 1

In the previous example, the local variable value was only created by the line value = ....

If this line is missing, for example in a function that only reads but does not modify the value of value, the function will use the global variable with the same name:

value = 1

def print_global_variable():    
    print("Value is", value)  # prints the global variable
    
print_global_variable()
Value is 1

If you want to modify a global variable inside a function, you must clarify at the beginning of the function that no new local variable should be created. This is done using the global keyword:

value = 1

def increment_value():
    global value
    value = value + 1  # writes to the global variable 'value'
    
print("Value is", value)
increment_value()
print("Value is", value)
Value is 1
Value is 2

This approach should, however, be avoided whenever possible. Other functions might rely on the value of the global variable not changing unexpectedly.

  1. What output does this program produce?

  2. Explain why the value of the global variable value does not change.

  3. Modify the function so that the global variable is actually changed.

Mutable vs. Immutable

Another interesting question is: What happens to function parameters if we modify them inside a function?
The answer can be seen with a simple test:

def add_something(a):
    a += 5

a = 2
print("a =", a)
add_something(a)
print("a =", a)
a = 2
a = 2

Although we modified a inside the function, the value of a outside the function does not change.

This is because when the function add_something is called, a copy of the reference to the value of a is passed. Since a is an immutable data type (integer), the operation a += 5 creates a new object that only the local variable a inside the function refers to.

It gets a bit more confusing, though. In the following example, the function parameter is a list, which is modified inside the function. If the function only worked with a copy, the list L should not have changed after the function call. However, this is clearly not the case:

def append_something(L):
    L.append(5)

L = [1,2,3,4]
print("List before function call  :", L)
append_something(L)
print("List after function call  :", L)
List before function call  : [1, 2, 3, 4]
List after function call  : [1, 2, 3, 4, 5]

Obviously, inside append_something, we did not work with a copy of the list, but directly with the object L.

To understand this behavior, it helps to take a closer look at how variables work in Python. Variables are identifiers for objects in memory.

Using the function id, which returns an identification number of an object, we can see where a variable’s value is stored in memory. In the following example, we create two variables that obviously have the same ID:

a = 5
b = 5
print("a :", id(a))
print("b :", id(b))
print("a and b are the same:", a is b)

b = b + 1
print("a :", id(a))
print("b :", id(b))
print("a and b are the same:", a is b)
a : 103280627849712
b : 103280627849712
a and b are the same: True
a : 103280627849712
b : 103280627849744
a and b are the same: False

This can be interpreted as a and b being essentially just names that we bind to an object (here the constant 5, which resides somewhere in memory).

Initially, both a and b point to the same object in memory. Therefore, id(a) and id(b) return the same identification number, and the expression a is b evaluates to True.

If the value of b is then changed (b = b + 1), the following happens:

  1. A new object with the value 6 is created.

  2. The name b is bound to this new object.

  3. a remains bound to the original object 5.

The original object in memory is therefore not modified; instead, a new object is created.

Let’s implement a similar test for list objects:

a = [1,2,3]
b = [1,2,3]

print("a :", id(a))
print("b :", id(b))

print("a and b are the same:", a is b)
a : 133909565145216
b : 133909565146688
a and b are the same: False

Here, a and b are lists with exactly the same content, but the names point to different objects in memory. This can be seen from the fact that id(a) and id(b) return different values, and the expression a is b evaluates to False.

Unlike the previously considered integers, a new object is created for each list, even if the content is identical.

Let’s slightly modify the example:

a = [1,2,3]
b = a

print("a :", id(a))
print("b :", id(b))
print("a and b are the same:", a is b)

b.append(4)
print("a :", id(a))
print("b :", id(b))
print("a and b are the same:", a is b)
print("a =", a)
print("b =", b)
a : 133909567138176
b : 133909567138176
a and b are the same: True
a : 133909567138176
b : 133909567138176
a and b are the same: True
a = [1, 2, 3, 4]
b = [1, 2, 3, 4]

By assigning b = a, we have bound b to the same object as a. Now we need to be careful. We only modified the object behind b using b.append(4), but since a and b are bound to the same object, this operation also affects a. The position of the object in memory does not change. Thus, the object itself is mutable.

As our examples have shown, an object of type int is immutable, which means it cannot be modified. If the value of an integer is changed, a new object is created, to which the original name is bound:

a = 1
print("a :", id(a))
a+= 1
print("a :", id(a))
a : 103280627849584
a : 103280627849616

Lists, on the other hand, are mutable and behave differently:

L = [1,2]
print("L :", id(L))
L.append(3)
print("L :", id(L))
L : 133909565149952
L : 133909565149952

This also explains the different behavior when using lists as function parameters. The behavior for immutable data types is easy to understand if we extend our example from above with a few print statements:

def add_something(a_):
    print("Function start    : a ->", id(a_), " Value =", a_)
    a_ += 1
    print("Function end      : a ->", id(a_), " Value =", a_)
    
a = 1
print("Before function call  : a ->", id(a), " Value =", a)
add_something(a)
print("After function call   : a ->", id(a), " Value =", a)
Before function call  : a -> 103280627849584  Value = 1
Function start    : a -> 103280627849584  Value = 1
Function end      : a -> 103280627849616  Value = 2
After function call   : a -> 103280627849584  Value = 1

The name a is initially bound to the object with the constant 1. The name is passed to the function add_something, now called a_ (for clarity; we could have kept it a), but it is still bound to the same object. Inside the function, an operation is applied to this object, here +=. A copy is created because integers are immutable, and the name a_ now points to this new object containing the constant 2. The name a outside the function add_something remains bound to the original object and is therefore unaffected by what happens inside the function.

If a in the previous example had been a list, i.e., a mutable object, an operation that modifies the list would not change the binding of the name to the object.

Conclusion:

  • Immutable objects (int, float, string, tuple) → modifications create new objects.

  • Mutable objects (list, dict, set, …) → modifications directly affect the passed object.

Recursive Functions

Recursive functions are functions that call themselves repeatedly, up to a certain termination condition. A typical example would be the following function for calculating the factorial:

def faculty(n):
    if n == 1: 
        # recursion termination condition
        return 1
    else:
        # recursive call
        return n * faculty(n-1)
    
print("5! =", faculty(5))
5! = 120

Note that many algorithms can be implemented both recursively and iteratively (using a loop). For efficiency reasons, the iterative variant is usually preferred in this case.