Functions#

Reminder: Indentation#

You already know that python uses indentation to define code blocks, for instance, to separate the inside from the outside of a loop:

print('before the loop')
for index in range(4):
    print('in the loop: index =', index)  # indented so inside the loop
print('after the loop')
before the loop
in the loop: index = 0
in the loop: index = 1
in the loop: index = 2
in the loop: index = 3
after the loop

Indentation matters:

print('before the loop')
for index in range(4):
    print('in the loop: index =', index)  # indented so inside the loop
    print('after the loop')
before the loop
in the loop: index = 0
after the loop
in the loop: index = 1
after the loop
in the loop: index = 2
after the loop
in the loop: index = 3
after the loop

Why functions?#

We have encountered builtin functions like print or len before. Functions typically receive inputs (arguments), contain code to manipulate the inputs, and often return a result: result = function_name(arguments).

Functions allow you to re-use and shorten your code. For instance, if you need to calculate the mean of values in a list in several places in your program, then you will duplicate a lot of code. Writing a mean function that contains the relevant code and can be applied to different lists throughout the program is useful.

data1 = [1235, 4235, 2365, 6346]
# compute the sum
data1_mean = 0
for d1 in data1:
    data1_mean += d1
# divide sum by number of data points
data1_mean = data1_mean / len(data1)
print(data1_mean)


data2 = [3523, 346346, 35,23,2345243, 5234,32412]
# compute the sum
data2_mean = 0
for d2 in data2:
    data2_mean += d2
# divide sum by number of data points
data2_mean = data2_mean / len(data2)
print(data2_mean)
3545.25
390402.28571428574

Above, we have applied the same computation to two different lists.Let’s to make this code more general, and turn it into a function.

First, we re-organize the code to separate what changes from what is the same in the two blocks of code above:

# we have different lists
data1 = [1235, 4235, 2365, 6346]
data2 = [3523, 346346, 35, 23, 2345243, 5234, 32412]

# What part of the code above is constant? What changes?

Now let’s put the code inside a function, that we want to call mean. The input to the function should be a list of numbers, let’s call it data. This will be the function argument. The output of the function should be the mean of the data, contained in the variable data_mean in the code above.

We want a function that we can it like so:

the_calculated_mean = mean(my_data)
# we have different lists
data1 = [1235, 4235, 2365, 6346]
data2 = [3523, 346346, 35, 23, 2345243, 5234, 32412]

# define a function with name "mean" and a single argument "data"

# we want to use the function like so:
the_mean_of_the_data1 = mean(data1)  # call the function by its name and provide a data list as the argument
print(the_mean_of_the_data1)

the_mean_of_the_data2 = mean(data2)  # call the function by its name and provide a data list as the argument
print(the_mean_of_the_data2)

A function has several advantages:

  1. It makes your code shorter

  2. It allows you to re-use your code elsewhere

  3. It helps to further separate program logic (calculation of the mean) from your data

The anatomy of a function#

  • The keyword def, followed by the name of the function.

  • Arguments following the name in parentheses, multiple arguments separated by commas.

  • On the definition, the arguments correspond to the names of the variables each input is assigned to inside the function.

  • The indented code that does what the function is supposed to do.

  • return followed by the variables whose values you want the function to produce as an output. Omit the return statement if you do not want to return anything. Caution: Lines of code in your function after return will not be executed - python will “exit” your function after return.

Running the code defining the function once will make it available anywhere in the notebook - it is added to the notebook’s namespace.

def function_name(argument1, argument2):
    # things to do with the arguments inside the function
    calculated_values = argument1 + argument2

    # if you want to get a value calculated inside the function
    # you must use a return statement
    return calculated_values

Function arguments#

Required and optional arguments#

A function can have zero, one, or more arguments

# function with zero arguments
def error():
    print('ERRRRROOOOR')

error()
ERRRRROOOOR

A function with three arguments:

def calculate_total(price, discount, tax):
    """Calculate the total cost after applying discount and tax."""
    print(f"Arguments {price=}, {discount=}, {tax=}")  # print the values of the arguments for illustration
    price_after_discount = price - (price * discount / 100)
    total_price = price_after_discount + (price_after_discount * tax / 100)
    return total_price

print(calculate_total(100, 10, 19))
print(calculate_total(100, 19, 10))
Arguments price=100, discount=10, tax=19
107.1
Arguments price=100, discount=19, tax=10
89.1

Since the arguments do not have a value defined in the function definition, you must always provide them when calling the function. If you omit one or provide too many, you produce an error:

calculate_total(100, 19)  # all arguments without default arguments are required - we have to give the exact number of arguments defined in the function, otherwise we get an error
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[84], line 1
----> 1 calculate_total(100, 19)  # all arguments without default arguments are required - we have to give the exact number of arguments defined in the function, otherwise we get an error

TypeError: calculate_total() missing 1 required positional argument: 'tax'
calculate_total(100, 19, 30, 50)  # too many arguments
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[85], line 1
----> 1 calculate_total(100, 19, 30, 50)  # too many arguments

TypeError: calculate_total() takes 3 positional arguments but 4 were given

By providing default values in the function definition, we can make arguments optional:

# if an argument is followed by a value in the function definition, that argument is now optional - if you omit it, it will have the default value given in the def statement.
def calculate_total(price, discount=0, tax=19):
    """Calculate the total cost after applying discount and tax."""
    print(f"Arguments {price=}, {discount=}, {tax=}")  # print the values of the arguments for illustration
    price_after_discount = price - (price * discount / 100)
    total_price = price_after_discount + (price_after_discount * tax / 100)
    return total_price

print(calculate_total(100))  # calculate the price with the default discount=0 and tax=19
print(calculate_total(100, 20))  # calculate the price with a discount of 20
print(calculate_total(100, 20, 9))  # calculate the price with a discount of 20 and reduced tax of 9
Arguments price=100, discount=0, tax=19
119.0
Arguments price=100, discount=20, tax=19
95.2
Arguments price=100, discount=20, tax=9
87.2

Positional vs. keyword arguments#

The arguments are assigned to variables in the function by position - the first argument is price, the second is discount etc. The arguments are used as so-called “positional arguments”.

print(calculate_total(100, 10, 19))   # 3 positional arguments - their "meaning" is given by their position
print(calculate_total(19, 10, 100))  # changing the position of the arguments changes their meaning
Arguments price=100, discount=10, tax=19
107.1
Arguments price=19, discount=10, tax=100
34.2

However, you can make your code more readable by providing the arguments with their names (also called “keywords). Then, the name of the arguments, not their position determines their meaning. In that keys, the arguments are used as so-called “keyword arguments”:

print(calculate_total(price=100, discount=20, tax=9))
print(calculate_total(discount=20, tax=9, price=100))  # the meaning of the arguments is now given by the keywords, their position does not matter anymore
Arguments price=100, discount=20, tax=9
87.2
Arguments price=100, discount=20, tax=9
87.2

When defining or using functions, positional arguments must always come before keyword arguments.

print(calculate_total(price=100, 20, 9))
  Cell In[89], line 1
    print(calculate_total(price=100, 20, 9))
                                          ^
SyntaxError: positional argument follows keyword argument

Clicker question “functions args”(Click me!)

Let’s define the following function. Which of these statements work?

def my_fun(a, b=0, c=0):
    return a + b + c

my_fun(10)
my_fun(a=10)
my_fun(b=0)
my_fun(a=10, c=10)
my_fun(b=10, a=10)
my_fun(a=10, 1)

Arguments can be of any type#

Anything can be an argument: floats, strings, int, lists, tuples, any other objects. The return statement is optional, too.

def print_name_and_age(name, age):
    print(f"{name} was born {age} years ago.")

print_name_and_age("Tom", 20)
Tom was born 20 years ago.

Functions can return zero, one, or multiple variables#

Functions without a return statement or with a blank return statement do not return anything:

def print_name_and_age(name, age):
    print(f"{name} was born {age} years ago.")
    # functions with no return statement (or blank return statement) will return nothing

print_name_and_age('Ole', 64)
Ole was born 64 years ago.

Technical side note: functions without a return statement or with a blank return statement return None, a special python type that indicates an missing or empty value:

output = print_name_and_age("Olaf", 64)
print(output, type(output))
Olaf was born 64 years ago.
None <class 'NoneType'>

To return one or multiple variables from inside the function, add their names in the return statement, with mutiple return values separated by a comma: return var1, var2. You then “catch” the returned variables like so: returned_var1, returned_var2 = my_function().

def sum_and_diff(a, b):
    the_sum = a + b
    the_diff = a - b
    return the_sum, the_diff  # return two variables from inside the function

returned_sum, returned_difference = sum_and_diff(10, 20)

# What is the type of "catch_as_single_variable" this?? HINT: Check the structure of return statemnent inside the function
catch_as_single_variable = sum_and_diff(10, 20)

The “scope” of a variable - local variables#

Why do we need to return variables from inside the function? It is because what you define inside a function is not accessible outside: all variables created inside functions are local variables for that function. To access these local variables outside of the function, you need to return them to the outside.

def area(radius):
    pi = 3.14 # inside the function we change the value of pi, but this is another pi
    the_area = pi * radius ** 2 # We calculate the area using the pi inside the function
    print(f'The value of pi inside the function is {pi}') # we print the pi inside the function
    return the_area

print(area(10)) # we print the value returned by our function
print(f'The value of pi outside the function is {pi}')  # pi only exists inside the function
The value of pi inside the function is 3.14
314.0
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[33], line 8
      5     return the_area
      7 print(area(10)) # we print the value returned by our function
----> 8 print(f'The value of pi outside the function is {pi}')  # pi only exists inside the function

NameError: name 'pi' is not defined

Functions have their own memory/variable space. We can re-define variables inside a function without it affecting the outside:

pi=4.3  # we define pi outside the function giving it a value
print(f'The value of pi outside and before the function is {pi}') # we print the pi outside the function

def area(radius):
    pi = 3.14 # inside the function we change the value of pi, but this is another pi
    the_area = pi * radius ** 2 # We calculate the area using the pi inside the function
    print(f'The value of pi inside the function is {pi}') # we print the pi inside the function
    return the_area

print(area(10)) # we print the value returned by our function
print(f'The value of pi outside and after the function is {pi}') # we print the pi outside the function
The value of pi outside and before the function is 4.3
The value of pi inside the function is 3.14
314.0
The value of pi outside and after the function is 4.3

But: While variables created inside a function are not accessible to the outside without returning them, every variable created outside is accessible inside the function. So things can leak into the function but not out of it.

This makes it hard to debug functions, since you don’t have easy access to variable inside the function. That’s why you should write functions from the inside out: First implement the inner logic using normal python code (so you can easily inspect the values of variables), then wrap the working code in a function.

pi = 3.14
def area(radius):
    print(f'The value of pi inside the function is {pi}') # we print the pi inside the function
    area = pi * radius ** 2 # because we are not defining any new pi inside the function, python searches the one outside the function
    return area

area(10)
The value of pi inside the function is 3.14
314.0

Clicker question “functions scope”(Click me!)

What is the value of x at the end of this code?

d = 10
def my_fun(a, b=0, c=0):
    d = 100
    return a + b + c + d

x = my_fun(20)

Documenting functions#

How do you know what a function does? You can inspect the code and try to infer what is going on. If you are lucky, the function is well documented with a so-called “doc string”.

The doc string is a block of text, enclosed by triple double quotes (""") and must contain the following information (but you can add more information if necessary):

  1. A one-sentence decription of what the function does.

  2. A block defining the function arguments and what they do.

  3. A block definign what the function returns

def mean(data):
    """Compute the mean of a list of numbers.

    Args:
        data (List of numbers)

    Returns:
        float: The mean
    """

    # compute the sum
    data_sum = 0
    for d in data:
        data_sum += d

    # convert the sum into the mean
    data_mean = data_sum / len(data)

    return data_mean

You can print the doc string using the help function:

help(mean)
Help on function mean in module __main__:

mean(data)
    Compute the mean of a list of numbers.

    Args:
        data (List of numbers)

    Returns:
        float: The mean