Functions

So far in this course we’ve encountered a few basic functions such as print, sum, len, and some others from the math module. However, we’ve largely glossed over the details of what functions are, how they work, and how we might create our own functions.

Function basics

A function is a piece of reusable code that takes some input(s), manipulates it/them in a useful way, and (usually) returns some output(s).

TipReminder

We call a function by typing its name followed by a set of brackets into which we place the inputs. For example

message = 'Here we are calling the print function!'

print(message)

We refer to the inputs to a function as arguments - just like in mathematics. So, in the above reminder, when we use the print function we would describe this more correctly as calling the print function with message passed as the only argument.

Some functions like print don’t return anything back to us - we don’t get any results or new variables. We’ve previously used the math module to obtain values of the cosine and sine functions, and in that case we are returned a single value corresponding to \(\cos\) or \(\sin\) evaluated at our specified angle. For example, \(\cos(\pi)\).

import math

math.cos(math.pi)
-1.0

We can place the result of this call to math.cos in a variable using the assignment operator =.

import math

value = math.cos(math.pi)
print(f'cos(π) = {value:.1f}')
cos(π) = -1.0

Defining functions

Now you’ve been reminded of how we call functions, let’s get on with writing some of your own.

To get started, we’ll use an example to take a look at the components that make up a function, and then in later sections we’ll delve into the specifics of how each works.

The following simple (and rather pointless!) add function sums two numbers

def add(a, b):
    result = a + b

    return result

The first line starts with the def (short for define) keyword - this tells Python that we are defining a new function.

We then follow this with the name of our function - here we’ve chosen add.

Then we have a set of brackets containing the two arguments to the function - here we’ve called them a and b.

We follow this with the contents of the function - the actual code that will run when we call this function. Notice that the code is indented by one tab (or four spaces) - this tells Python that this code is part of the function.

In this function the variables a and b are added together to make the variable result. The return keyword is used to indicate what we’d like the output of the function to be. In this example we are returning the variable result.

NoteSyntax

The general syntax for defining a function is

def name_of_function(input_argument1, input_argument2,...):
    code_inside_the_function
    some_more_code
    and_so_on...

    return return_values

where the code inside of the function is indented by one tab (or four spaces) - Jupyter usually does this automatically.

To use this function we simply call it like any other, for example we can compute \(5+6\) by imputting two int values.

add(5, 6)
11

but we could also pass a pair of variables

number_1 = 1.25
number_2 = 2.15

add(number_1, number_2)
3.4

You might be wondering - what about a and b, why have we used number_1 and number_2? The answer to this question requires us to learn about something called scope.

Scoping

Functions are machines - they take an input and process it to give an output. Within the internal workings of a function there need to be a set of names for the input variables, output variables, and any other variables that the function might create and use when it is executed. We call this “list of names” the scope of the function.

Let’s see an example - a function which converts a frequency in Hertz to an energy in Joules.

def freq_to_energy(freq_hz):

    planck = 6.626E-34 # J s

    # Energy in Joules
    # E = h * nu
    energy_j = freq_hz * planck

    return energy_j

The variable planck is Planck’s constant and is used to convert from frequency to energy.

Let’s use this on a frequency value, perhaps for an x-ray with \(\nu=5\times10^{17}\mathrm{\ Hz}\).

freq_to_energy(5E17)
3.313e-16

Great! Now what happens if we try to print the planck variable?

freq_to_energy(5E17)
print(planck)
Traceback (most recent call last):
  File "<python-input-7>", line 2, in <module>
    print(planck)
          ^^^^^^
NameError: name 'planck' is not defined

But, didn’t we just define the planck variable inside our function? Yes! But planck only exists within the function freq_to_energy - more specifically we say that this is a local variable which exists within the local scope of that function. For the same reason, printing the variables freq_hz and energy_j outside of freq_to_energy will give an error - they exist only within the local scope of freq_to_energy.

Modify the definition of freq_to_energy so that planck is printed when the function is called. Why does this work?

Each function has its own separate scope, allowing us to reuse variable names inside of functions without worrying about them already being defined, or changing definition.

So if a local scope describes the set of variables within a function, then you might wonder if there’s an equivalent name for everything that exists outside of functions? Yes, we call this the global scope - this is where all of the objects you’ve defined outside of functions reside.

For example, if you define a variable (outside of a function), e.g.

a = 5

we say that it resides in the global scope of the program. Simple!

What can be slightly confusing is that objects in the global scope are also accessible inside of a function.

Here’s an example

number_ten = 10

def scope_example(a, b):
    result = (a - b) / number_ten

    return result

We use the variable number_ten inside of our function but we’ve defined it elsewhere. This is what we call a global variable.

Warning

The use of global variables is extremely bad practice in python - your code will rapidly become unreadable and highly prone to errors.

To see why - use a new cell and redefine number_10 as the string '10', then call the function scope_example again - what happens?

There is one situtation in which global variables should not be looked upon with disdain - constants. In science we refer to fixed quantities as constants, for example the ideal gas constant \(R=8.314\, \mathrm{\ J\ K^{-1}\ mol^{-1}}\) is a constant. In Python we use a similar definition: constants are variables which remain fixed throughout the code - they’re defined once at the start of a program and used repeatedly.

Constants are usually denoted by CAPITAL LETTERS, but are not a new type of object - this is simply a style choice that tells us not to modify this variable.

As an example, we could define the number of seconds in an hour at the start of our program.

SECONDS_IN_HOUR=3600

and then use this constant in a function

def hours_to_seconds(hours):
    return hours * SECONDS_IN_HOUR

Arguments

In our previous examples we’ve seen that we can have different numbers of arguments to a function. We can even have zero arguments

def zero_args_func():

    print('This function takes zero arguments')
    print('and returns nothing')
    return

zero_args_func()
'This function takes zero arguments'
'and returns nothing'
Warning

You still need to include brackets when calling a function without arguments - otherwise Python will treat your command as a variable name.

What happens if you provide more arguments to a function than it needs?

Try calling zero_arg_funcs with multiple arguments - what does the error message say?

By default, arguments to a function are positional in nature - we have to give them in the order that the function expects. The following code executes successfully.

def print_name_and_age(name, age):

    print(f'Your name is {name}, and you are {age:d} years old.')

print_name_and_age('Tim', 25)

but what happens if we switch the order of the arguments? Below we’ve provided an age where the name should be and vice-versa.

print_name_and_age(25, 'Tim')
Traceback (most recent call last):
  File "<python-input-10>", line 1, in <module>
    print_name_and_age(25, 'Tim')
    ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
  File "<python-input-9>", line 3, in print_name_and_age
    print(f'Your name is {name}, and you are {age:d} years old')
                                             ^^^^^^^
ValueError: Unknown format code 'd' for object of type 'str'

In this case we see an error - because the print statement expects age to be an `int - and we can correct our call to the function to have the correct argument order.

Consider the following function

from scipy import constants

def ideal_gas_pressure(volume, moles, temperature)
    return moles * constants.R * temperature / volume

We want to compute the pressure of \(1\ \mathrm{mol}\) of gas at \(298\ \mathrm{K}\) with a volume of \(5\ \mathrm{m^3}\).

which of the following calls is correct?

ideal_gas_pressure(298, 1, 5)
ideal_gas_pressure(5, 298, 1)
ideal_gas_pressure(5, 1, 298)

Do any of these result in an error?

What about if we only want to provide an argument some of the time. Then we can use a keyword argument which is optional and has a default value that is used if that keyword is not specified.

Consider the function below, we define moles as a keyword argument with a default value of 1.

def ideal_gas_pressure(volume, temperature, moles=1)
    return moles * constants.R * temperature / volume

ideal_gas_constant(5, 298)
ideal_gas_constant(5, 298, moles=1)
ideal_gas_constant(5, 298, moles=25)

The first two calls give the same value. In the first call we don’t specify a value for moles and so it defaults to 1, just as we defined. In the second call we set moles=1 and see the same result, and in the final call we set moles=25 and see a different result.

Warning

When defining a function, make sure that all positional arguments are written before any keyword arguments.

def correct(a, b, c=1)
    return a*b*c

is correct

but the following gives an error

def correct(a, b=1, c)
    return a*b*c

Keyword arguments are particularly useful when they are combined with booleans and logic - something we’ll cover in Session 4.

Return

Functions use the return keyword to specify what the output should be. For example, our add function returns the result of adding a and b

def add(a, b):
    result = a + b

    return result

but what happens if we don’t return anything.

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

add(5, 10)

Nothing! The calculation occurs, and is stored in result, but then the function ends, doesn’t return anything, and result is lost.

What happens if we print this

print(add(5, 10))
None

or use the type function

type(add(5, 10))
NoneType

so we recieve None - which is Python’s type for nothing or null and represents a lack of data.

This might seem useless, but there are situations where a function doesn’t need to return a value - print is one such function.

type(print('The print function does not return anything!'))
The print function does not return anything!
NoneType

You can see this even easier with a variable

print_return_value = print('This will allow us to store whatever the print function returns!')

print(print_return_value)
This will allow us to store whatever the print function returns!
None

We can also choose to return more than one output variable, for example this function returns the sum and difference of two numbers

def add_and_subtract(a, b):
    addition = a + b
    subtraction = a - b

    return addition, subtraction

add_and_subtract(3, 9)
12 -6

When more than one value is returned, the function is actually returning a tuple

result = add_and_subtract(3, 9)
print(result)

type(result)
(12, -6)
tuple

We can access the elements of a tuple using indexing, as we learned in Session 2. Alternatively, we can use multiple assignment to store each value in a separate variable.

addition, subtraction = add_and_subtract(3, 9)

print(addition, subtraction)
print(type(addition), type(subtraction))
12 -6
int, int

Why do we use functions?

In the first Session’s synoptic exercises, you calculated the wavelength of the first line in each of the Lyman, Balmer, and Paschen hydrogen emission series.

Notice in the answers that the same lines were repeatedly copied and pasted for each series, resulting in quite a messy piece of code with lots of repetition.

We could instead define a function which calculates the energy of a given transition between two states \(n_\mathrm{i}\) and \(n_\mathrm{f}\).

import scipy

def calc_rydberg_wavelength(n_i, n_f):

    iwavelength = scipy.constants.Rydberg * (1/n_f**2 - 1/n_i**2)

    wavelength = 1 / iwavelength
    wavelength *= 10**9

    return wavelength

This could then be called for each transition to give a much more readable piece of code which doesn’t write the mathematical operations three times.

lyman_lambda = calc_rydberg_wavelength(2, 1)
balmer_lambda = calc_rydberg_wavelength(3, 2)
paschen_lambda = calc_rydberg_wavelength(4, 3)