Up to this point in the course, the functions we have defined all share one feature: they take a fixed number of arguments, equal to the number of parameters specified in the function header. For example, a function with the following header:
def f(n: int, items: list) -> ...:
takes exactly two arguments, where the first argument is an
int
and the second a list
.
However, we’ve seen a few different examples of built-in Python
functions that seem to take in a varying number of arguments. Here’s an
example with the round
function:
>>> round(3.46, 1) # round to 1 decimal place
3.5
>>> round(3.46) # round to the nearest integer
3
Similarly, the list.pop
method takes an index as an
optional argument; if this argument is omitted, the last
element in the list is removed and returned.
>>> numbers = [10, 20, 30, 40]
>>> numbers.pop(1)
20
>>> numbers.pop()
40
This is a pretty neat Python feature that makes functions more flexible in their usage. Now, let’s see how to define our own functions that take optional arguments!
In our syntax for a function header, we specified two properties of each parameter, its name and its type annotation:
# parameter definition syntax
def ...(<parameter_name>: <parameter_type>, ...) -> ...:
It turns out that there is a third property that we can specify: a default value for the parameter, to be used when no argument is passed for that parameter. Here is the syntax for doing so:
# parameter definition with default value syntax
def ...(<parameter_name>: <parameter_type> = <default_value>, ...) -> ...:
Let’s see an example of this. Suppose we want to define a function
that takes a number n
and by default returns
n + 1
, but allows the caller to specify an optional
step
amount to increase by.
def increment(n: int, step: int = 1) -> int:
"""Return n incremented by step.
If the step argument is omitted, increment by 1 instead.
"""
return n + step
Let’s experiment with this function in the Python console:
>>> increment(10, 2) # n = 10, step = 2
12
>>> increment(10) # n = 10
11
In the latter case, no argument is passed for the step
parameter, and so the default value 1
is used instead,
causing 10 + 1 == 11
to be returned.
One interesting point about our definition of increment
is that its header includes both a non-optional (or mandatory)
parameter n
and an optional parameter step
.
This is perfectly allowed by the Python interpreter, but with one
important caveat: in the function header, optional parameters must
be written after mandatory parameters. This is to ensure that the
Python interpreter is able to unambiguously determine which argument in
a function call is associated with each parameter. Indeed, if we violate
this restriction, the Python interpreter treats our code as the most
severe form of error—a SyntaxError
!
def increment(step: int = 1, n: int) -> int:
"""Return n incremented by step.
If the step argument is omitted, increment by 1 instead.
"""
return n + step
# Running the above definition in the Python console produces:
def increment(step: int = 1, n: int) -> int:
^^^^^^
SyntaxError: non-default argument follows default argument
One of the most common source of bugs in Python is using a mutable object as a default value. We illustrate this with an example:
def add_num(num: int, numbers: list[int] = []) -> list[int]:
"""Append num to the given numbers, and return the list.
If no numbers are given, return a new list containing just num.
>>> my_numbers = [1, 2, 3]
>>> add_num(10, my_numbers)
[1, 2, 3, 10]
>>> add_num(10)
[10]
"""
numbers.append(num)return numbers
This code looks correct—if no argument for numbers
is
passed in, an empty list is used instead, causing [num]
to
be returned. However, this code is actually incorrect, and has a very
surprising behaviour when called multiple times without the
numbers
argument:
>>> add_num(10)
10] # Looks okay...
[>>> add_num(20)
10, 20] # Wait, what? Should be [20]...
[>>> add_num(30)
10, 20, 30] # ??? Should be [30]...
[>>> add_num(40)
10, 20, 30, 40] # I see the pattern, but why?! [
To understand what’s going on, you need to know the key principle
governing how the Python interpreter handles default values:
every default value is an object that is created when the
function is defined, not when the function is called. So in our
add_num
case, the default value []
is a single
list object that is created when the Python interpreter executes the
function definition, and this object is shared across all calls to
add_num
. If one call mutates this object, then all
subsequent calls will use that mutated object as their default value.
This is why each call to add_num
above seems to “remember”
the previous calls: the default value was mutated by each of the
previous calls, and is not “reset” to []
.
Yikes. This is unexpected and an easy trap to fall into if
you aren’t looking for it, so a general code style principle is to never
use mutable objects as default values. A common alternate approach is to
use None
as the default value and then explicitly check for
a None
value in the code. This results in a function body
that’s a bit longer, but avoids this pitfall. Here is how we could apply
this technique to our add_num
function:
from typing import Optional
def add_num(num: int, numbers: Optional[list[int]] = None) -> list[int]:
"""Append num to the given numbers, and return the list.
If no numbers are given, return a new list containing just num.
>>> my_numbers = [1, 2, 3]
>>> add_num(10, my_numbers)
[1, 2, 3, 10]
>>> add_num(10)
[10]
"""
if numbers is None:
return [num]
else:
numbers.append(num)return numbers