Lecture 4 (Functions)

Download the original Jupyter notebook

In the previous lecture, we wrote a short piece of code that determined if a number is prime. Then, we attempted to write a bit more complex code, that will list all prime numbers in a given range. The high-level idea for this was relatively natural: once we have code that checks if a number is prime, we can just add on top of it additional loop, to execute this code for every number in some range. We reused the previous code by copy-pasting it inside a larger loop, and modified accordingly.

This process ended up being less cognitively taxing than trying to develop the entire prime-listing procedure from scratch in one go: we split it into a simpler problem, solved this one, and used the solution to solve the more complex one.

Unfortunately, the final code was again unnecessarily complex: this separation between checking whether a number is prime, and listing all primes was not apparent in the final code.

Functions

Functions in most programming languages provide a way of structuring your code into small, self-sufficient pieces that should have well-defined functionality, and are - by themselves - easier to grasp. We have already used some built-in functions implicitly, without taking about what they are. For example, the following is a function call to a function print:

print("hello")
hello

Today we will see how to define our all function. To the first approximation, you can think of a function definition as giving a user-defined name to a specific block of code. For example, the code below defines a new function greet:

def greet():
    print("Hello")
    print("How are you doing?")

Note that executing the cell above will not print out any result. At this stage the code of the function (consisting of print function calls) is not executed: only a new function is defined.

The basic syntax for a function definition (without any arguments) is as in the example above. Specifically, we define a function by using the keyword def, followed by a custom name of a new function, then parenthesis, and a colon:

def function_name():
    function body
    can contain usual python code
    some more code...

The function name can be any identifier we want, starting with a letter or underscore, followed by letters, digits, and underscores. We should try to avoid names that are already used, especially the built-in identifiers.

Once we have defined a function, we can call this function at any other point in the code. To do this, we just write function_name() (remember to put opening and closing parenthesis, even if they are empty, to indicate calling the function with this name).

Once we call the function, the control flow will jump to the function body, and start executing the instruction line-by-line, until it reaches the end of the function body, at which point it will return to executing the instructions after the function call.

We can see it on the following example: the function greet itself prints two line of text, then the interpreter will execute the function call print("Great"), and then we call a function greet again, which prints the same two lines of text.

greet()
print("Great")
greet()
Hello
How are you doing?
Great
Hello
How are you doing?

Note: Try to run this code in a VS Code debugger, and follow which instructions are called in which order line-by-line.

Usually this basic functionality is not enough. We would like to create functions to which we can pass some inputs, so that they will act on those inputs. For instance, we might want modify the function greet, so that we can provide it a name of a person being greeted.

The general syntax for definining a functions taking few arguments looks like below

def function_name(arg1, arg2, arg3):
    function_body

Where arg1, arg2, arg3 can be any identifiers (i.e. start with a letter, followed by letters, digits and underscores) - and of course we can have any number of them.

Inside the function body we can use arg1, arg2, arg3 the same way we use any other variables. When we call the function, we need to specialize the values for those arguments, and the variables will be initialized with those values.

Let us try to see it on a specific example, and add a parameter name to the function greet. Inside the function body, we can use name as any other variable:

def greet(name):
    print("Hello " + name)
    print("How are you doing?")

When we call the function greet now, we need to specify a value for the parameter name, putting it in the parenthesis:

greet("John")
Hello John
How are you doing?
greet("Alice")
Hello Alice
How are you doing?

We have seen this before with a function sqrt:

import math
math.sqrt(5)
2.23606797749979

Note that this function call not only takes a parameter (in this case 5), but it can be interpreted as an expression, with some value (in this case 2.23606797749979), which we can then assign to a variable, and process in some further way.

When we want to achieve this efect with our custom-defined function, we will use the keyword return. Let us try to define a short function that takes a single argument x and returns the square of this argument:

def square_number(x):
    return x * x
square_number(5)
25
z = square_number(5)
z
25

Let us try to use it in a bit more complicated example.

Exercise. Define a function find_square_root that takes as an argument a single integer number x. It should find and return an integer number k such that k*k == x if one exists, and -1 otherwise.

Solution. The simplest way to do this (but by far not most efficient) is to just iterate over all numbers i from 0 to x+1 and for each i check if its square is x. Note that once we have found a number like this, we can directly call return i: this will break from all the nested loops defined inside of the function (in this case a single for loop), and proceeds directly to execute the code that called the function (putting the value of variable i as the value of the function call).

If, after checking all numbers from 0 to x we did not find a square root of x, we can just return -1: no element was found:

def find_square_root(x):
    for i in range(x+1):
        if i*i == x:
            return i
    return -1

Let us try it on a few examples:

find_square_root(25)
5
find_square_root(24)
-1
find_square_root(16)
4

We can see that the type of the value returned by our find_square_root function is int:

x = find_square_root(24)
type(x)
int

Exercise 2 Write from scratch (or reuse the code from the previous lecture), a function is_prime that takes a single integer argument q. It should return True if q is prime, and False otherwise.

def is_prime(q):
    if q < 2:
        return False
    for i in range(2, q):
        if q % i == 0:
            return False
    return True

Exercise 3 With the function is_prime already defined, write a functio print_all_primes that takes a single argument n (with integer value), and prints all prime numbers in the range from 2 to n. Do not re-implement checking if the number is prime again, instead use the call to the previously defined is_prime function,

def print_all_primes(n):
    for j in range(2, n+1):
        if is_prime(j):
            print(j)

Exercise 4 Modify the is_prime function to speed it up: notice that once the divisor i you are checking satisfies i*i > q, there is no need to look for further divisors: if the number q did not have any divisor so far, it will not have a larger divisor either.

def is_prime(q):
    if q < 2:
        return False
    for i in range(2, q):
        if i*i > q:
            return True
        if q % i == 0:
            return False
    return True

Local and global variables

For the sake of the exercise, let us rewrite the code to print all primes in a given range using a while loop, instead of a for loop:

n = 100
j = 2
while j < n:
    if is_prime(j):
        print(j)
    j = j+1
2
3
5
7
11
13
17
19
23
29
31
37
41
43
47
53
59
61
67
71
73
79
83
89
97

Staring at this code, it makes sense: we initialize the counter j to be 2, and in each iteration, as long as j < n, we check if j is prime, print it, and regardless of the answer we increase j. Certainly it does not run into an infinite loop, since the counter j is increased in each iteration.

Or is it?

Each iteration of the loop is calling the function is_prime which has its own code. What would happen if the function is_prime internally changed the value of j to 2? This seemingly innocent code would just loop forever, and there would have been no way of figuring this out just by looking at this code in separation: one would have to inspect a code of every function called in the while loop, and every function called by those functions, etc.

Let us try to see it on an example:

def is_prime(q):
    j = 2
    
    if q < 2:
        return False
    for i in range(2, q):
        if q % i == 0:
            return False
    return True
n = 100
j = 2
while j < n:
    if is_prime(j):
        print(j)
    j = j+1
2
3
5
7
11
13
17
19
23
29
31
37
41
43
47
53
59
61
67
71
73
79
83
89
97

The code does not loop forever, it works exactly as intended, even though we have instruction j=2 at the beginning of the is_prime function. Why?

A naive design of a programming language like this would have left to an incredibly difficult code to maintain: there would be no way of deciding whether a specific piece of code is correct, by just looking at this piece of code in separation, since any of the functions called could have internally changed the values of any variables.

Because of this, Python (as most other programming languages), have a notion of “local” and “global” variables (and indeed, usage of global variables is typically discouraged).

When Python sees an assignment instruction j=2 inside the function is_prime it does not treat it as changing the value of the global variable j; instead it is creating new local variable, j, visible only from inside this call to the function is_prime, and assigning it value to 2 (while not affecting the values of any other global variables/local variables, that may have the same name j).

Within this call to the funciton is_prime, every reference to j will affect this new local variable, and will not affect other local/global variables with the same name. Let us see this on some simple examples.

In this code I define a new function greet. The body of the function starts with an assignment operations blah = "John". This creates a new local variable blah with value “John”, and we can access the value of this local variable within this call to the function greet:

def greet():
    blah = "John"
    print("Hello " + blah)
greet()
Hello John

If the global variable blah is already defined outside of this function, its value will not be affected by the instruction blah = "John" inside of the function greet

blah = "Jackie"
greet()
print(blah)
Hello John
Jackie

If we define a local variable foobar inside a function greet2, we will not have access to this variable after the function greet2 completes execution:

def greet2():
    foobar = "Alice"
    print("Hello " + foobar)
greet2()
print(foobar)
Hello Alice
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[131], line 2
      1 greet2()
----> 2 print(foobar)

NameError: name 'foobar' is not defined

On the other hand, in this piece of code, the function greet3 is trying to read the value of variable blah, but nowhere in its code we have assignment to a variable blah. In this case, the python will treat this read operation as refering to the global variable blah, since it exists:

def greet3():
    print("Hello " + blah)
blah = "Anna"
greet3()
Hello Anna

Finally, maybe least intuitive example so far, let us consider the following code:

def greet4():
    print("Hello "+ blah)
    blah = "John"

What will happen if we call greet4()? Since there is an assignment blah = "John" in the code of this function, it creates the local variable blah for the function call greet4. As such, all usages of the name blah in this function refer to that local variable, even the reference that comes before the assignment operation! But at this stage, the local variable blah is not initialized yet, and we will see an error, even though the global variable blah has well-defined value:

blah = "Joe"
greet4()
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
Cell In[139], line 2
      1 blah = "Joe"
----> 2 greet4()

Cell In[137], line 2, in greet4()
      1 def greet4():
----> 2     print("Hello "+ blah)
      3     blah = "John"

UnboundLocalError: cannot access local variable 'blah' where it is not associated with a value

Lists

So far all our programs were operating on small amounts of data: we created some number of variables each contained one value (typically int or float, sometimes bool).

In most of the programs we will want to process large data sets: a database containing informations about all employees in a given company, all financial transactions in a specific institutions, or a sequence of measurmenets of some aparatus.

We can store those datasets using lists in Python: a list is just a sequence of an unknown number of values, each value can be of arbitrary type.

To create an empty list, we use the [] syntax. For example, we can create an empty list, and assign it to a new variable my_list, like below:

my_list = []

We can also create a list that initially contains several elements, by putting those values in square brackets, separated by a comma:

my_list = [2,3,5,7,9,11]

To read of the $k$-th element of the list, we use notation my_list[k].

Important The lists in Python are indexed from 0. That is my_list[0] contains the first element of the list, my_list[1], the second element of the list, and so on. Let’s see:

my_list[0]
2
my_list[1]
3
my_list[2]
5

If we try to access element beyond the end of the list, we will get a runtime error:

my_list[100]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[147], line 1
----> 1 my_list[100]

IndexError: list index out of range

To access the length of the list, we can use the built-in function len:

len(my_list)
6

With this knowledge, we can try to write a loop that goes over all elements of the list, and do some specific operation to each of them - this is particularly useful pattern (we will see later in the course slightly shorter ways of doing it in Python, but let us start with this minimal way).

Exercise 5. Write a function print_all_values that takes a single argument lst which is a list, and call print function for each element of the list lst.

def print_all_values(lst):
    for i in range(len(lst)):
        print(lst[i])
print_all_values([2,3,5,7,11])
2
3
5
7
11

Note Note that len(lst) outputs length of the list: for instance, for a list [2,3,4,7,11], we have len(lst) == 5. Hence range(len(lst)) is a range $0, 1, \ldots 4$ (it stops at len(lst)-1). Since the elements of the lists are indexed from 0, the loop for i in range(len(lst)): will iterate over all i that are valid indices for the list lst. We can now access the i-th element of the list by lst[i], and print it.

Note: It is very important to keep in mind which variables contain an index to a list which we are processing, and which variable contain the value at that index. In this case i will be an index, taking values 0, 1, 2, 3, 4 for our example list. To get the values at the approriate position, we need to use syntax lst[i] (which will be $2,3,4,7,11$ accordingly, on this example).

Mixing those two concepts is a source of frequent bugs, especially when first learning programming.

Exercise 6. Write a function that takes list as a parameter, multiply all elements in this list and returns the result.

def product(lst):
    result = 1
    for i in range(len(lst)):
        result = result * lst[i]
    return result

New for syntax: iterating over all elements of the list.

Very we will want to perform specific action for each element of the list, but we do not particularly care about the positions of those elements. Introducing a new variable for the index i, and then operating only on lst[i] is a bit more verbose than necessary. As such, Python provides a more concise syntax

for x in lst:
    block_of_code

will execute block of code, where variable x will be set to values in the list x one by one. For example this code:

lst = [2,3,5,7,11]
for x in lst:
    print(x)
2
3
5
7
11

Is equivalent to this code:

lst = [2,3,5,7,11]
for i in range(len(lst)):
    print(lst[i])
2
3
5
7
11

In one case, the value i is the index into the list, and to get the appropriate value, we need to access lst[i]. In the other case x is assigned already values from the list lst one by one.

Exercise 7. Write a function that takes list as a parameter, multiply all elements in this list and returns the result. Use the for x in lst syntax now.

def product_2(lst):
    result = 1
    for x in lst:
        result = result * x
    return result
product_2([2,3,5,7])
210

Exercise 8. Do the same with print_all_values function.

def print_all_values(lst):
    for x in lst:
        print(x)
print_all_values([2,3,5,7,11])
2
3
5
7
11

Note Neither of those two constructions supersedes the other. The for x in lst is more concise when we do not care about the index into the list, for i in range(len(lst)) (and variants of it), provide more versatility.