Functions#

By this point you have seen and used a number of functions, such as print(), sum(), and math.sqrt(), and the general concept of functions was briefly introduced earlier.

In this section we will cover how functions work in some more detail, and explain how you can write your own functions.

Packaging your code into self-contained functions can help you to organise your programs as they become more complex, and also provides a way to write a section of code once and then use it multiple times in some later calculation.

To review the key points from the earlier introduction: a function is a piece of reusable code that takes zero or more inputs, performs a specific computation, and returns an output.

To use a function (known as calling the function) we type the function name, followed by a pair of brackets. Anything inside the brackets specifies the inputs to the function. These inputs are called arguments, and are said to be passed to the function:

Defining a function#

To write your own function in Python you start with the function definition. This consists of a single line that starts with the keyword def, followed by the function name, and a pair of brackets (these contain any arguments, which we will discuss in detail below), before finishing with a colon :.

Function names are variables and follow the same rules regarding allowed names as other variables. The convention is for functions to be written in snakecase where each word is written entirely in lowercase, with underscores between words, e.g. sum() or kinetic_energy() or distance_between_two_atoms().

After the definition line comes the code that runs when you call the function. This code must be indented, in the same way as we saw previously for loops. The end of this indented block of code marks the end of the function.

The simplest function definition uses no arguments and takes the following form.=

def print_hello_world():
    print("Hello World!")

Running this code does not produce an output, but instead creates a variable called print_hello_world that is assigned to the code specified in the function definition.

print_hello_world
<function __main__.print_hello_world()>
type(print_hello_world)
function

To call this function, we write the function name, followed by a pair of brackets.

print_hello_world()
Hello World!

Note the difference between the variable that refers to the code (no brackets) and calling the function stored in this variable (with brackets)

print_hello_world
<function __main__.print_hello_world()>
print_hello_world()
Hello World!

Function arguments#

Functions can be given one or more arguments. These represent data that we pass into the function when we call it, that can then be used inside the function. One way this is useful is if you want to write a piece of code that will always perform the same computation on a range of different data.

def greet(name):
    print(f'Hello {name}')

In this example, we have defined a function greet() that takes one argument name. You can think of this argument as a variable that exists inside the function. To call this function now, and pass a value into the argument name we provide the value we want to pass inside the brackets in the function call:

greet("Ben")
Hello Ben

We can pass any value, or a variable that is assigned to a value, into this function. When we call the function, a new variable name is created, which is assigned to the value of the object passed to the function.

my_name = "Drew"
greet(my_name)
Hello Drew

If the function requires an argument, and we forget to pass one, an error is raised:

greet()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[10], line 1
----> 1 greet()

TypeError: greet() missing 1 required positional argument: 'name'

or if we pass the wrong number of arguments:

greet("Ben", "Drew")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[11], line 1
----> 1 greet("Ben", "Drew")

TypeError: greet() takes 1 positional argument but 2 were given

To define a function with more than one argument, we can include more argument variable names in the function definition.

def double_greet(name1, name2):
    print(f"Hello {name1} and {name2}.")
double_greet("Ben", "Drew")
Hello Ben and Drew.

Here is a more practical example, where we use a function to return the roots of the quadratic equation \(ax^2+bx+c\), where \(a\), \(b\), and \(c\) are each passed to the function as ordered arguments.

import math

def quadratic_roots(a, b, c):
    d = b**2 - 4*a*c
    r1 = (-b + math.sqrt(d)) / 2 / a
    r2 = (-b - math.sqrt(d)) / 2 / a
    return r1, r2

print(quadratic_roots(1, 3, -10))
(2.0, -5.0)

Keyword, Optional, and Default arguments#

In the examples above, we have assigned values to the arguments of a function based on the order of these arguments in the function call.

For example:

quadratic_roots(1, 3, -10)

calls the quadratic_roots function and assigns a=1, b=3, c=-10, because the arguments appear in this order in the function definition above.

We can also assign values to function arguments explicitly using keyword arguments.

quadratic_roots(b=3, c=-10, a=1)
(2.0, -5.0)

We can mix positional and keyword arguments in a function call, but the positional arguments must come first:

quadratic_roots(1, c=-10, b=3)
(2.0, -5.0)
quadratic_roots(a=1, -10, b=3)
  Cell In[17], line 1
    quadratic_roots(a=1, -10, b=3)
                                 ^
SyntaxError: positional argument follows keyword argument

Similarly, calling a function using a keyword argument that is not included in the function definition also raises an error:

quadratic_roots(a=1, b=3, f=-10)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[18], line 1
----> 1 quadratic_roots(a=1, b=3, f=-10)

TypeError: quadratic_roots() got an unexpected keyword argument 'f'

Functions can also be defined with optional arguments by giving these arguments default values in the the function definition.

def ch40208_greet(name, day="Tuesday"):
    print(f"Hello {name}, and welcome to the {day} lesson.")

In this case we have one required argument, and one optional argument. If we call the function and pass one value, this has the argument variable name assigned to it, while the argument variable day is assigned to the default value of "Tuesday".

ch40208_greet("Ben")
Hello Ben, and welcome to the Tuesday lesson.

What if it is not Tuesday though? Then we can call this function with two input parameters and specify the day:

ch40208_greet("Ben", "Friday")
Hello Ben, and welcome to the Friday lesson.

Note that the sequence of values passed into the function maps onto the sequence of argument variables assigned to these values.

ch40208_greet("Friday", "Ben")
Hello Friday, and welcome to the Ben lesson.

While required arguments must be passed in the same order as the argument list in the function definition, optional arguments passed with keywords can appear in any order (although they must appear after all the required arguments.

def ch40208_greet_and_info(name, day="Tuesday", topic="Topics in Computational Chemistry"):
    print(f"Hello {name}. Welcome to the {day} lesson on {topic}.")
ch40208_greet_and_info("Lucy")
Hello Lucy. Welcome to the Tuesday lesson on Topics in Computational Chemistry.
ch40208_greet_and_info("Lucy", "Friday")
Hello Lucy. Welcome to the Friday lesson on Topics in Computational Chemistry.
ch40208_greet_and_info("Lucy", "Friday", "functions")
Hello Lucy. Welcome to the Friday lesson on functions.
ch40208_greet_and_info("Lucy", topic="functions (again)")
Hello Lucy. Welcome to the Tuesday lesson on functions (again).

In this last example, we pass two parameters, but the second parameter is assigned the keyword topic. When the ch40208_greet_and_info() function is called, the first parameter is assigned to the required name argument, and the second parameter is assigned to the topic argument, corresponding to the keywork. We do not provide a value for the day optional argument, so the function runs with day assigned to the default value of "Monday".

Returning values from functions#

A function can return some value, that we can then use again in our code.

numbers = [1, 2, 3, 4, 5]
step_total = sum(numbers)
print(step_total)
15

In this example, the sum function adds the values in the list passed as the required argument, and returns this sum (15).

We can return values in our own functions using the return keyword. For example, we could write our own replacement for sum():

def my_sum(list_of_numbers):
    total = 0
    for n in list_of_numbers:
        total += n
    return total
print(my_sum(numbers))
15

Our definition for a function was that it always returns a value. And you might remember checking the return value of print():

type(print("The print() function returns"))
The print() function returns
NoneType

If we do not specify a return value in our own functions, then these too will return None:

type(ch40208_greet("Jacob"))
Hello Jacob, and welcome to the Tuesday lesson.
NoneType

Scope#

The term scope refers to where a variable is visible in a Python program. Variables can be visible in one part of a program, but not in another part.

def add(a, b):
    return a + b

print(add(3, 5))

print(a)
    
8
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[33], line 6
      2     return a + b
      4 print(add(3, 5))
----> 6 print(a)

NameError: name 'a' is not defined

In this example, the variables a and b are only defined inside the function add. If we try to refer to either of these variable names outside the function, the we get a NameError saying that these names have not been defined.

To describe this behaviour in terms of scope, we would say that a and b are defined in the local scope of the function add. Outside this scope, these variables are not defined. One consequence of this is we can write modular code where the same variable names get reused in different functions, while each function remains self-contained.

def add(a, b):
    return a + b

def multiply(a, b):
    return a * b

print(add(2,3))
print(multiply(2,3))
5
6

If we define variables outside of functions then they are defined in global scope. Variables defined in global scope are also visible in the local scope of any functions defined further down in the same piece of code.

x, y = 1, 3

def add_to_x(y):
    return x + y

print(add_to_x(5))
6

In this example, we define two variables in global scope: x and y. We then define another variable y in the local scope of the function add_to_x, which is set to the value 5 when we call the function. Because x is not defined in the local scope of the function, the Python interpreter looks to see if there is a variable x defined in the outer scope. If there is, it assumes we are referring to that variable.

We can make things a bit clearer by adding some print() statements:

x, y = 1, 3

print(f'outside the function: x = {x}, y = {y}')

def add_to_x(y):
    print(f'inside the function: x = {x}, y = {y}')
    return x + y

print(add_to_x(5))

print(f'outside the function: x = {x}, y = {y}')
outside the function: x = 1, y = 3
inside the function: x = 1, y = 5
6
outside the function: x = 1, y = 3

Notice how when we return outside the function, we are back in the global scope, and the name y now refers to the value 3 defined in the first line of the code.

Mixing global and local scope can get quite confusing. In fact, it is usually good practice to only refer to local variables inside functions, unless it is very clear from the code what they refer to. One of the benefits of dividing code up into functions is that it makes your code modular, and individual functions can be reused across different bits of code. If the behaviour of a function depends on the value of a global variable, however, it can become very difficult to correctly reason about the behaviour of that function, giving a prime opportunity for hard-to-squash bugs in your code.

An example of where you might want to use a global variable inside a function might be to have a global variable for a physical constant, such as the Boltzmann constant.

k_boltz = 1.380649e-23 # Boltzmann constant in J K^-1

def boltzmann_factor(energy, temperature):
    return math.exp(-energy/(k_boltz * temperature))

Python also has a set of built-in functions and variables that are always available, like print() and len(). These live in built-in scope. The Python interpreter checks built-in scope after it has checked local scope and global scope. This means that if you define a new variable with the same name as a built-in function you might get unexpected behaviour and a particularly hard-to-spot bug.

Local scope does not only apply to functions; it also applied to list comprehensions:

x = 3
print([x+1 for x in (6,7,8)])
print(x) # refers to x in global scope
[7, 8, 9]
3

Docstrings#

Computer code is rarely used once, when it is written, and then left. More often code is used over weeks, or months, or years, possibly with pieces of code being used in multiple projects or by multiple users. Code that solves your specific problem might also be easier to adapt from pre-existing code (either your own or someone elses), than to write from scratch each time.

While we might all think that it is obvious what our code does and how it works at the time we write it, there is no guarantee that someone else will think the same thing. Or that if you want to reuse or modify a piece of code in six months that it will make any sense.

Having to figure out exactly what a piece of code does by reading the source code is, at best, time consuming; at worst it becomes impossible.

For these reasons, it is strongly recommended that you document your code, to give enough information to a future user of the code (most likely future-you) to be able to pick it up and use it correctly.

Functions support a particular kind of inline documentation called a docstring. A docstring is a string that provides documentation about a function: usually a summary of what the function does, and information about any required or optional arguments, and any data returned by the function.

A docstring is written as a multi-line string, which is defined in Python by a block of text that starts and ends with triple quotes: """ or '''.

For example, here is a function kinetic_energy() that calculates the kinetic energy of a particle. The function takes two required arguments mass and velocity and returns the kinetic energy, calculated as \(E_\mathrm{KE} = \frac{1}{2}mv^2\).

def kinetic_energy(mass, velocity):
    """
    Determine the kinetic energy of a particle.
    
    Args: 
        mass (float): Particle mass (kg)
        velocity (float): Particle velocity (m/s)
    
    Returns:
        (float): Particle kinetic energy (J)
    """
    kinetic_energy = 0.5 * mass * velocity ** 2
    return kinetic_energy

In this example the docstring is:

    """
    Determine the kinetic energy of a particle.
    
    Args: 
        mass (float): Particle mass (kg)
        velocity (float): Particle velocity (m/s)
    
    Returns:
        (float): Particle kinetic energy (J)
    """

The first line gives a brief summary of what the function does.

Below this there is an Args: section, with each argument listed on a separate line underneath.

At the end there is a Returns: section, which gives the type of the returned value, and an explanation of what this is.

A docstring can contain any text in any format. There are a few common conventions used in most Python code. In this example we have used Google Style, but other dosctring styles do exist.

Once a function has been defined, or imported, the docstring is stored as a special variable __doc__, which allows us to read this information.

print(kinetic_energy.__doc__)

We can do the same thing for functions that are part of the standard library or that we have imported from external modules:

print(sum.__doc__)
from math import sqrt
print(sqrt.__doc__)

If we are working inside a Jupyter notebook we can use a special ? syntax to open the docstring for a function in a separate popout window.

kinetic_energy?

Exercises:#

1#

Write a function to calculate the distance between two atoms. Then rewrite the exercise in the loops section to utilise this function. Make sure to include a docstring describing the purpose of the function and outlining the arguments.

2#

The Einstein model for the heat capacity of a crystal is

\[C_{V,m} = 3R\left(\frac{\Theta_E}{T}\right)^2\frac{\exp\left(\frac{\Theta_\mathrm{E}}{T}\right)}{\left[\exp\left(\frac{\Theta_\mathrm{E}}{T}\right)-1\right]^2}\]

where the Einstein temperature, \(\Theta_\mathrm{E}\) is a constant for a given material.

Write a function to calculate \(C_{V,m}\) at 300 K for (a) sodium (\(\Theta_\mathrm{E}\) = 192 K) and (b) diamond (\(\Theta_\mathrm{E}\) = 1450 K).