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)
/tmp/ipykernel_2026/2890983093.py in <module>
----> 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)
<ipython-input-13-9b362522ff63> in <module>
----> 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.

Optional arguments

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

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

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 "Monday".

ch40208_greet("Ben")
Hello Ben, and welcome to the Monday LOIL.

What if it is not Monday 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 LOIL.

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 LOIL.

Optional arguments are also called keyword arguments, because they can be set by specifying keywords in the function call statement:

ch40208_greet("Drew", day="Friday")
Hello Drew, and welcome to the Friday LOIL.

Calling a function with a keyword argument that is not included in the function definition raises an error.

ch40208_greet("Lucy", topic="functions")
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-23-219ece4fff36> in <module>
----> 1 ch40208_greet("Lucy", topic="functions")

TypeError: ch40208_greet() got an unexpected keyword argument 'topic'

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="Monday", topic="Topics in Computational Chemistry"):
    print(f"Hello {name}. Welcome to the {day} LOIL on {topic}.")
ch40208_greet_and_info("Lucy")
Hello Lucy. Welcome to the Monday LOIL on Topics in Computational Chemistry.
ch40208_greet_and_info("Lucy", "Friday")
Hello Lucy. Welcome to the Friday LOIL on Topics in Computational Chemistry.
ch40208_greet_and_info("Lucy", "Friday", "functions")
Hello Lucy. Welcome to the Friday LOIL on functions.
ch40208_greet_and_info("Lucy", topic="functions (again)")
Hello Lucy. Welcome to the Monday LOIL 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 Monday LOIL.
NoneType

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__)
    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)
    

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__)
Return the sum of a 'start' value (default: 0) plus an iterable of numbers

When the iterable is empty, return the start value.
This function is intended specifically for use with numeric values and may
reject non-numeric types.
from math import sqrt
print(sqrt.__doc__)
Return the square root of x.

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 to 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

Write a function that implements the second-order rate equation,

\[ [A]_t = \frac{[A]_0}{[A]_0kt + 1}, \]

where \([A]_t\) is the concentration at time \(t\), \([A]_0\) is the initial concentration and \(k\) is the rate constant (note this a rearrangement from the familiar way of writing this equation where \([A]_t\) is a reciprocal).

Plot the data below, as \([A]_t\) (\(y\)-axis) against \(t\) (\(x\)-axis), with a scatter plot (where you use 'o') and make sure to label your axes.

Using, either a loop or the NumPy array operations and the function that you have defined, test if the following values of \(k\) and \([A]_0\) accurately model the reaction by plotting the model data as a line on top of your scatter plot.

  • \(k = 0.006\) mol-1 dm3 s-1 and \([A]_0 = 0.6\) mol dm-3

  • \(k = 0.0006\) mol-1 dm3 s-1 and \([A]_0 = 0.4\) mol dm-3

  • \(k = 0.6\) mol-1 dm3 s-1 and \([A]_0 = 0.2\) mol dm-3

\(t\)/s

0

600

1200

1800

2400

\([A]_t\) / mol dm-3

0.400

0.350

0.311

0.279

0.254

Worked Example