Functions#
In our previous labs, we have already been using functions: the print
function, the type
function, functions from the math
library etc. So far, we’ve largely glossed over the details and have just introduced various functions as and when they become relevant. We are now going to dive headfirst into exactly what functions are, how they work and how you can write your own.
A function is a piece of reusable code that takes some inputs, manipulates them in a useful way, and (usually) returns some output. To call a function, we type the function’s name followed by a set of brackets in which we place the inputs:
message = 'Here we are calling the print function'
print(message)
Here we are calling the print function
The inputs to a function placed in the brackets are usually referred to as arguments. We could describe the line of code above as “calling the print function with message
passed as the only argument”. In this case, we are calling a built-in function: the print
function is made available to us in Python by default. We have also seen examples where we have to import
the function we want to use (i.e. it is not built-in):
import math
math.cos(math.pi)
-1.0
Here we have imported the math
library and used this to call the cos
function with \(\pi\) as the only argument. We mentioned previously that functions usually return an output. Here, that output is -1.0
: the value of \(\cos{\pi}\).
Defining your own functions#
Now we’ve reminded ourselves of what functions are and what they do, let’s get on with writing some of our own!
def add(a, b):
result = a + b
return result
In this simple example, we have defined an add
function which outputs the sum of two numbers. Let’s go line-by-line:
def add(a, b):
This first line starts with the def
(short for define) keyword: this is how we tell Python that we want to define a new function.
def
is followed by add
, this is the name we want to give our new function.
Next comes a set of brackets (a, b)
. This is where we specify what arguments we want our function to take. Here we want to add
two numbers together, so we specify two arguments: a
and b
.
Important
We are not specifying at this stage what a
and b
are, these are just the names for our arguments. Only when we call the function later will a
and b
take on actual values.
Just like if
statements and for
loops, a function definition line ends with a colon :
and is followed by an indent.
result = a + b
The second line does the actual calculation we are interested in, adding the value of a
to the value of b
and storing this in a new variable result
.
return result
Finally, the last line starts with the return
keyword. This is how we tell Python what the output of the function is. In this case, we want to return
the sum of a
and b
, so we return our result
variable.
Having defined our add
function, we can call it just like any other function:
add(5, 10)
15
When we call our add
function, Python goes and looks at its definition:
def add(a, b):
result = a + b
return result
The arguments we supplied were \(5\) and \(10\), so what Python does under the hood is:
# What Python is doing when we type add(5, 10)
a = 5
b = 10
result = a + b
And then result
is returned as the output of the function call.
It is worth emphasising at this point that we only run the function definition once. In other words we do not need to run:
def add(a, b)
result = a + b
return result
Every time we call the add
function, only once beforehand.
All about arguments#
Now that we have a general overview, let’s talk a bit more about the specific components of a function, starting with arguments.
In our previous example, it does not matter what order we supply our arguments in:
add(5, 10)
15
add(10, 5)
15
This is simply a reflection of the fact that addition is symmetric, \(a + b = b + a\). The vast majority of functions do not have this symmetry, for example \(a \div b \neq b \div a\):
def divide(a, b):
result = a / b
return result
divide(10, 5)
2.0
divide(5, 10)
0.5
This is why you will sometimes read about positional arguments: the position/order matters.
As opposed to positional arguments, we can also make use of keyword arguments:
def divide(a, b, round_to_int=False):
result = a / b
if round_to_int:
return round(result)
else:
return result
Here we have added an example keyword argument to our divide
function: round_to_int
. Notice that this argument is followed by the assignment operator =
and the False
keyword, this indicates that we are assigning False
to round_to_int
by default.
Keyword arguments are optional:
divide(10, 3)
3.3333333333333335
This means that we can call the divide
function with only the two positional and required arguments, and it will function just like before. If we also set the optional keyword argument:
divide(10, 3, round_to_int=True)
3
Our output is now rounded to the nearest integer as per the logic in the function definition.
Note that keyword arguments cannot come before positional arguments in the function definition:
def divide(a, round_to_int=False, b):
result = a / b
if round_to_int:
return round(result)
else:
return result
Cell In[13], line 1
def divide(a, round_to_int=False, b):
^
SyntaxError: parameter without a default follows parameter with a default
You can also imagine other errors that we might encounter depending on the arguments that a function expects compared to those that we actually pass to it:
divide('10', 5)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[19], line 1
----> 1 divide('10', 5)
Cell In[13], line 2, in divide(a, b, round_to_int)
1 def divide(a, b, round_to_int=False):
----> 2 result = a / b
4 if round_to_int:
5 return round(result)
TypeError: unsupported operand type(s) for /: 'str' and 'int'
Here’s a familiar example from our previous labs. We have passed '10'
as the first argument, which is then assigned to a
. Our function definition instructs Python to divide a / b
, which is impossible if a
is a string: hence the TypeError
. There are ways in which we can make our functions at least somewhat tolerant to bad user input, but we will come to this later.
Both the add
and divide
functions that we have written require two arguments, but there is nothing stopping us from writing functions that take more or less arguments. In fact, you can write functions that take zero arguments:
def zero_arguments():
print('This function takes zero arguments!')
Even though there are no arguments, notice that the brackets are still required before the colon :
in the function definition. The same goes for when we call zero_arguments
:
zero_arguments()
This function takes zero arguments!
If we forget the brackets, Python will treat zero_arguments
as a variable:
zero_arguments
<function __main__.zero_arguments()>
Note
This is true for all functions, regardless of the number of arguments they take. To call a function, we must follow its name with a set of brackets (argument_1, argument_2 ...)
.
Of course, if zero_arguments
takes… zero arguments, then you can imagine what might happen if we try to pass some anyway:
zero_arguments(5)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[4], line 1
----> 1 zero_arguments(5)
TypeError: zero_arguments() takes 0 positional arguments but 1 was given
What to 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
What happens if we don’t return
anything?
def add(a, b):
result = a + b
add(5, 10)
The answer appears to be… nothing: running add(5, 10)
produces no output at all. This is actually a little deceptive, as Python is returning something. We can see this if we print
the output of the add
function:
print(add(5, 10))
None
Or we could use the type
function:
type(add(5, 10))
NoneType
If you do not specify what to return
from a function, the default is to return None
.
We have not encountered None
before, but the idea is relatively simple. None
is Python for “nothing” or “null”, it is a way of representing a lack of data. If our function doesn’t return
anything, then None
represents exactly that: it is the absence of data. We have actually already met a few functions that don’t return
anything:
type(print('The print function does not return anything!'))
The print function does not return anything!
NoneType
It may seem strange, but the print
function does not return
anything, hence the type
of its output is NoneType
: it returns None
. Notice the distinction between what is displayed on the screen using print
versus what is actually returned. You can see this even more clearly using variables:
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
Our examples have always returned one output, but Python allows us to return as many outputs as we would like! Let’s say I want to write a function that calculates the sum of two numbers and the difference between them:
def add_and_subtract(a, b):
addition = a + b
subtraction = a - b
return addition, subtraction
add_and_subtract(3, 9)
(12, -6)
As you can see above, add_and_subtract
returns two values: addition
and subtraction
; the syntax for returning multiple values is simply to separate them by commas return addition, subtraction
.
When we return
multiple values, they are output from the function as a tuple
:
result = add_and_subtract(3, 9)
print(result)
type(result)
(12, -6)
tuple
Each element of this tuple
can be accessed in all the ways we learnt about back in lab 2. It is relatively common in the context of functions returning multiple outputs to use multiple assignment to store each output in a variable:
addition, subtraction = add_and_subtract(3, 9)
print(addition, subtraction)
12 -6
Scope#
Returning again to our divide
function, this time without the keyword argument:
def divide(a, b):
result = a / b
return result
Let’s talk about a
and b
. As discussed prior, these are the arguments to the divide
function. When we call divide
, the first argument we pass is assigned to a
and the second is assigned to b
.
divide(60, 10)
6.0
In other words, when we call divide
, a
and b
are used as variables. When we ran divide(60, 10)
above, Python implicitly ran:
a = 60
b = 10
So we would imagine that if we ran print(a)
, we should get \(60\), right?
print(a)
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[22], line 1
----> 1 print(a)
NameError: name 'a' is not defined
As you can see, the answer is no. Not only do we not get \(60\), attempting to print(a)
doesn’t work at all!
Error
This is an example of a NameError
, which communicates that we have attempted to access a variable that does not exist.
But hang on, how can a
not be defined if we have just run the divide
function? This is a consequence of local scope:
a = 5
b = 10
print(a, b)
divide(60, 10)
print(a, b)
5 10
5 10
Hopefully the example above clears things up a bit. The idea of scope is simple: some variables are only defined (i.e. they only exist) within certain bounds. In the example above, we explicitly assign a = 5
and b = 10
. We then call the divide
function, which also assigns a
and b
, but to different values (here \(60\) and \(10\)). Nonetheless, if we print(a, b)
before and after calling divide
, we see that they have not changed! This is because the variables in the divide
functions are restricted to local scope. In other words, variables defined within functions are not defined outside of those functions.
It is worth emphasising that the opposite is not true. Variables defined in global scope are indeed defined within local scope:
global_scope = 10
def scope_example(a, b):
result = (a - b) / global_scope
return result
Here we have defined a scope_example
function, notice that it refers to the global_scope
variable which is not an argument to the function. Despite the fact that global_scope
is not explicitly passed to scope_example
:
scope_example(10, 5)
0.5
We find that it is able to access global_scope
all the same.
It is generally a bad idea to mix up global and local variables. For example, if we change the value of global_scope
:
global_scope = 'This is now a string.'
scope_example(10, 5)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[26], line 3
1 global_scope = 'This is now a string.'
----> 3 scope_example(10, 5)
Cell In[24], line 4, in scope_example(a, b)
3 def scope_example(a, b):
----> 4 result = (a - b) / global_scope
6 return result
TypeError: unsupported operand type(s) for /: 'int' and 'str'
Our scope_example
function suddenly stops working, as global_scope
is now a string. This behaviour can lead to very troublesome bugs, as it is all too easy to accidentally change something in global scope that then propagates into any functions that use use that variable in local scope.
There is one situation in which it is more acceptable to rely on global scope:
R = 8.314
def ideal_gas_volume(p, n, T):
return (n * R * T) / p
Here we define the variable R = 8.314
in global scope. We then access R
in the local scope of the ideal_gas_volume
function. Objectively, this is the same situation as our previous example with global_scope
and the scope_example
function. The key detail is that here R
is (roughly) the gas constant, so we do not expect its value to change. If we access global scope from within a function, but we expect that the variables we are accessing will not change, then this shouldn’t cause us any unexpected problems, as we can rely on R
always being 8.314
.