Lesson A8 – Functions

Throughout the previous parts of this course we encountered many functions already. We know that a function is an object that can be called, that arguments can be passed to it and that functions return other objects.

returned_object = fxn(argurments)

You might have noticed, that we sometimes pass arguments directly. We call these arguments positional arguments.

[1]:
print("positional argument")
positional argument

Positional arguments are often required arguments. The order of positional arguments passed matters.

[2]:
range()  # Needs a positional argument
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-2-b1150d557b1d> in <module>
----> 1 range()  # Needs a positional argument

TypeError: range expected 1 arguments, got 0
[3]:
range(10)  # One argument is interpreted as stop
[3]:
range(0, 10)
[4]:
range(1, 10)  # Two arguments are interpreted as start, stop
[4]:
range(1, 10)

On the other hand we used some other arguments explicitly with a name. We call these arguments keyword arguments. These arguments are optional and their order does not matter, since they are passed with a name. Keyword arguments always need to come after all positional arguments.

[5]:
print("positional and keyword arguments", end="!")
positional and keyword arguments!

Note: We distinguish function arguments as positional arguments (required and interpreted by order) and keyword arguments (optional and identified by name).

We will now see how to write our own functions and how to control their behaviour by specifying their arguments. The purpose of functions is to bundle blocks of code in reusable objects under a label for identification. After loops, functions are our main tool to execute code many, many times that we have only written once.

Function definitions

Besides using functions provided by Python modules, we can write our own functions. The def directive defines a code block as a function.

[6]:
def our_first_function():
    """This is our first function"""

    print("Hey,")
    print("this is our first function!")

Our function does not expect any arguments: the parenthesis in the above definition are empty. Calling it, executes the function body. Obviously a function needs to be defined first, before we can call it.

[7]:
our_first_function()
Hey,
this is our first function!

The call of our_first_function printed a message to the screen. It did, however, not return a result. Precisely, if we try to store the return value of the function into a variable, we obtain None.

[8]:
returned = our_first_function()
print(returned)
Hey,
this is our first function!
None

The print output to the screen is a side-effect of our function. Often we want functions to return something to the main program (e.g. the result of a calculation). We implement this with a return statement in the function definition.

[9]:
def return_one():
    """Always describe your function here

    Returns:
        1
    """

    return 1
[10]:
returned = return_one()
print(returned)
1

Advanced: A function that does not return anything explicitly, returns None by default. Leaving out the return statement within a function definition is equivalent to a bare return or an explicit return None.

def no_return():
    pass  # do nothing

def bare_return():
    return

def explicit_return():
    return None

Once a return is encountered during the execution of the function, we exit the function. Only the first return statement met will have an effect. Everything after a return statement is not executed.

[11]:
def return_this_or_that():
    if True:
        return True
    return False

return_this_or_that()
[11]:
True

If we want to make our function handle arguments, we need to define what it expects in the first line of our definition. We put the argument within the parenthesis, we have left empty so far.

[12]:
def repeat_print(s, i=2):
    """Repeated calls of `print()`

    Args:
       s (str): String object to print
       i (int): How often to print

    Returns:
       None
    """

    for _ in range(i):
        print(s)
[13]:
# Call of repeat_print
repeat_print("Hello!")
Hello!
Hello!

We use a required positional argument s here and an optional keyword argument i. When we call this function we need to pass at least one argument to it. The string "Hello!" we passed is interpreted by position as a value for s. Within the function, s is now defined as s = "Hello!". By default, if we do not pass anything else, i is set to i = 2 (two repeated prints). Optionally we can also set the parameter i to a different integer. In this case we need to pass it by name (i=3) after the positional argument.

[14]:
# Call of repeat_print
repeat_print("Hi!", i=3)
Hi!
Hi!
Hi!

Note, that it is always a good idea to document what your function does with a comment directly at the top of the function body. These comments, called docstrings are also used when you call help() on your function.

[15]:
help(repeat_print)
Help on function repeat_print in module __main__:

repeat_print(s, i=2)
    Repeated calls of `print()`

    Args:
       s (str): String object to print
       i (int): How often to print

    Returns:
       None

Let’s use what we have learned about functions for a more useful example now. When we talked about “Working with numbers” in an earlier exercise, we used Python to compute a Coulomb interaction between charges at a given distance. At this point we needed to change the parameters manually, if we wanted to get the result for a different value, e.g. another distance. A function can help us here to make the calculation convenient for any given value.

[16]:
def get_force_coulomb(r, q1=1.602e-19, q2=1.602e-19, k=9e9):
    """Compute Coulomb interaction

    Uses $F = k \frac{q_1 q_2}{r^2}$

    Args:
        r (float): Distance (m)

    Keyword args:
        q1, q2 (float): Charges of interacting particles (C).
            Defaults to elemental charges.
        k (float): Force constant of the medium (N m$^2$ C$^{−2}$).
            Defaults to vacuum.
    """

    return (k * q1 * q2) /  r**2  # Calculate force

Now we can calculate the interaction for a whole range of values and in other contexts without much code re-writing.

[17]:
# Generate 10 values in the open interval (0.1, 1) nm
r = [r * 0.1 * 1e-9 for r in range(1, 11)]

for r_ in r:
    print(f"{r_:.2e} m: {get_force_coulomb(r_):.2e} N")
1.00e-10 m: 2.31e-08 N
2.00e-10 m: 5.77e-09 N
3.00e-10 m: 2.57e-09 N
4.00e-10 m: 1.44e-09 N
5.00e-10 m: 9.24e-10 N
6.00e-10 m: 6.42e-10 N
7.00e-10 m: 4.71e-10 N
8.00e-10 m: 3.61e-10 N
9.00e-10 m: 2.85e-10 N
1.00e-09 m: 2.31e-10 N

Advanced

Scopes

When we deal with functions we need to beware of scopes, that means we need to know about which variable is defined where. So far, when we assigned a value to a variable, this variable could be accessed from everywhere in our Python session. We say, they were globally defined or global variables. When we define variables within functions, however, these are only valid within this function. The variable s for example used within our repeat_print function, has no meaning outside this function.

[18]:
print(s)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-18-0ff1b7208845> in <module>
----> 1 print(s)

NameError: name 's' is not defined

While we can access global variables from within functions, variables created within functions are only locally available.

[19]:
def use_global():
    print(x)

x = 1
use_global()
1
[20]:
def use_local():
    y = 1
    print(y)

use_local()
print(y)
1
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-20-c6e3a9c81e29> in <module>
      4
      5 use_local()
----> 6 print(y)

NameError: name 'y' is not defined

Be careful with the use of global variables within your functions! When a function relies on a global variable it is more dependent on the context in which it is used and less re-usable in other situations. As a rule of thumb, a proper function should receive everything it needs as an argument and return the outcome of its execution as a return value. Side-effects as modifying global variables should be avoided if possible to make your functions clean and general.

Advanced: There are of course cases where global variables may be useful, for example when you have program-wide physical constants or settings defined. Be aware, that there is, however, no (easy) way to protect global data from changing in Python. Consider the use of local variables first, before you make your functions depend on globals. Passing a big data structure to a function to be modified locally is unproblematic, by the way, as Python passes objects essentially by reference. There is no copying of data involved and basically no performance issue with using local variables over globals.

When we define a local variable within a function, that has been also defined globally, the local value takes precedence. Precisely, Python creates a local variable and uses this instead of the global one.

[21]:
def use_local_instead_of_global():
    x = 2
    print(x)

x = 1
use_local_instead_of_global()
print(x)
2
1

A common mistake, leading to a strange error, is to try to modify a globally defined object from within a function with a statement that includes an assignment.

[22]:
def cant_do_this():
    string = string.replace("ABC", "XYZ")

string = "ABC"
cant_do_this()
print(string)
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
<ipython-input-22-276a5619292d> in <module>
      3
      4 string = "ABC"
----> 5 cant_do_this()
      6 print(string)

<ipython-input-22-276a5619292d> in cant_do_this()
      1 def cant_do_this():
----> 2     string = string.replace("ABC", "XYZ")
      3
      4 string = "ABC"
      5 cant_do_this()

UnboundLocalError: local variable 'string' referenced before assignment

In this case the string = statements tells Python to create the local variable string. But at the same time string.replace() requires that string already exists. While string exists globally, we just said that we want to use string as a local variable. Python can not handle this and raises an error. You can, however, modify global objects from within functions without local assignment:

[23]:
def can_do_this():
    list_.append(1)

list_ = []
can_do_this()
print(list_)
[1]

You can also circumvent the local/global misunderstanding of Python and avoid the UnboundLocalError by stating explicitly to use the global object. In this case Python does not create a local variable string on assignments.

[24]:
def now_can_do_this():
    global string
    string = string.replace("ABC", "XYZ")

string = "ABC"
now_can_do_this()
print(string)
XYZ

Note: It is sometimes helpful in general to avoid naming conflicts between global or local variables in the first place, e.g. by choosing generic place-holders as arguments to functions and descriptive names for variables outside functions. In our get_force_coulomb example we used r in three different roles: as local variable, as global sequence, and as control variable in a list comprehension. We could have chosen three distinct names to make their role more clear. You can also choose to mark global variables with a leading underscore (_globvar) or to put variables supposed to be static constants in all uppercase (OUTPUT). The naming convention section of PEP8 is a good standard reference if you want to learn more.

Scopes can be confusing at times, in particular when scopes are nested. The global scope is always the outermost layer in which variables can be defined (the session- or module-level). The local scope is always the innermost layer, enclosed by the lowest level function.

[25]:
def demonstrate_scopes():

    x = "I am local to 'demonstrate_scopes'"

    def modify_global():
        global x
        x = "I was changed by 'modify_global'"

        y = "I am local to 'modify_global'"
        print(y)

    print(x)
    modify_global()

x = "I am global"
print(x)
demonstrate_scopes()
print(x)
I am global
I am local to 'demonstrate_scopes'
I am local to 'modify_global'
I was changed by 'modify_global'

Between the local and the global layer, Python does also know the non-local layer. It addresses the next higher enclosing scope seen from the current local scope. You may not need this anytime soon in practice but it helps to understand scopes and where variables are valid. Let’s end this tutorial with an advanced example on this.

[26]:
def demonstrate_scopes():
    x = "I am local to 'demonstrate_scopes'"

    def modifiy_non_local():
        nonlocal x
        x = "I was changed by 'modify_non_local'"

    def modify_global():
        global x
        x = "I was changed by 'modify_global'"

    print(x)
    modifiy_non_local()
    print(x)
    modify_global()
    print(x)

x = "I am global"
print(x)
demonstrate_scopes()
print(x)
I am global
I am local to 'demonstrate_scopes'
I was changed by 'modify_non_local'
I was changed by 'modify_non_local'
I was changed by 'modify_global'