\( \newcommand{\NOT}{\neg} \newcommand{\AND}{\wedge} \newcommand{\OR}{\vee} \newcommand{\XOR}{\oplus} \newcommand{\IMP}{\Rightarrow} \newcommand{\IFF}{\Leftrightarrow} \newcommand{\TRUE}{\text{True}\xspace} \newcommand{\FALSE}{\text{False}\xspace} \newcommand{\IN}{\,{\in}\,} \newcommand{\NOTIN}{\,{\notin}\,} \newcommand{\TO}{\rightarrow} \newcommand{\DIV}{\mid} \newcommand{\NDIV}{\nmid} \newcommand{\MOD}[1]{\pmod{#1}} \newcommand{\MODS}[1]{\ (\text{mod}\ #1)} \newcommand{\N}{\mathbb N} \newcommand{\Z}{\mathbb Z} \newcommand{\Q}{\mathbb Q} \newcommand{\R}{\mathbb R} \newcommand{\C}{\mathbb C} \newcommand{\cA}{\mathcal A} \newcommand{\cB}{\mathcal B} \newcommand{\cC}{\mathcal C} \newcommand{\cD}{\mathcal D} \newcommand{\cE}{\mathcal E} \newcommand{\cF}{\mathcal F} \newcommand{\cG}{\mathcal G} \newcommand{\cH}{\mathcal H} \newcommand{\cI}{\mathcal I} \newcommand{\cJ}{\mathcal J} \newcommand{\cL}{\mathcal L} \newcommand{\cK}{\mathcal K} \newcommand{\cN}{\mathcal N} \newcommand{\cO}{\mathcal O} \newcommand{\cP}{\mathcal P} \newcommand{\cQ}{\mathcal Q} \newcommand{\cS}{\mathcal S} \newcommand{\cT}{\mathcal T} \newcommand{\cV}{\mathcal V} \newcommand{\cW}{\mathcal W} \newcommand{\cZ}{\mathcal Z} \newcommand{\emp}{\emptyset} \newcommand{\bs}{\backslash} \newcommand{\floor}[1]{\left \lfloor #1 \right \rfloor} \newcommand{\ceil}[1]{\left \lceil #1 \right \rceil} \newcommand{\abs}[1]{\left | #1 \right |} \newcommand{\xspace}{} \newcommand{\proofheader}[1]{\underline{\textbf{#1}}} \)

12.3 Functions with Optional Parameters

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!

Specifying parameter default values

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

Warning: do not use mutable objects as default values!

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