\( \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}}} \)

2.2 Defining Our Own Functions

Python provides many built-in top-level functions and methods for us, but as we start writing more code, it is essential for us to be able to create our own functions specific to the problem we are solving. In this section, we’ll learn how to define our own top-level functions in Python. And later on, we’ll study how to define our own data types and methods as well.

First, let’s recall how we define a function in mathematics. We first specify the function name, domain, and codomain: for example, \(f: \R \to \R\). Then, we write the function header and body, usually in a single line: for example, \(f(x) = x^2\). We do this so often in mathematics that we often take parts of this for granted, for example leaving out the domain/codomain specification, and usually choosing \(f\) as the function name and \(x\) as the parameter name. However, the functions we’ll implement in Python are much more diverse, and so it will be important to be explicit in every part of this process.

Defining a Python function

Here is the complete definition of a “squaring” function in Python. Take a moment to read through the whole definition, and then continue reading to learn about this definition’s different parts.

def square(x: float) -> float:
    """Return x squared.

    >>> square(3.0)
    9.0
    >>> square(2.5)
    6.25
    """
    return x ** 2

This function definition is the most complex form of Python code we’ve seen so far, so let’s break this down part by part.

  1. The first line, def square(x: float) -> float: is called the function header. Its purpose is to convey the following pieces of information:

    • The function’s name (square).
    • The number and type of arguments the function expects. A parameter is a variable in a function definition that refers to a argument when the function is called. In this example, the function has one parameter with name x and type float.
    • The function’s return type, which is the type following the ->, float. In this example, the function’s parameter and return type are the same, but this won’t always be the case.

    The syntax for a function header for a unary function is:

    def <function_name>(<parameter_name>: <parameter_type>) -> <return_type>:

    Compared to our mathematical version, there are two main differences. First, we chose the name square rather than f as the function name; in Python, we will always pick descriptive names for our functions rather than relying on the conventional “\(f\)”. And second, we use data types to specify the function domain and codomain: the code x: float specifies that the parameter x must be a float value, and the code -> float specifies that this function always returns a float value.

    We can express this restriction in an analogous way to \(f: \R \to \R\) by writing float -> float; we call float -> float the type contract of the square function.

  2. The next seven lines, which start and end with triple-quotes ("""), is called the function docstring. This is another way of writing a comment in Python: text that is meant to be read by humans, but not executed as Python code. The goal of the function docstring is to communicate what the function does.

    The first part of the docstring, Return x squared., is an English description of the function. The second part might look a bit funny at first, since it seems like Python code:Or more precisely, it looks like the Python console!

    >>> square(3.0)
    9.0
    >>> square(2.5)
    6.25

    This part of the docstring shows example uses of the function, just like the examples we showed of built-in functions in the previous section. You can read the first example literally as “when you type square(3.0) into the Python console, 9.0 is returned” and the second as “when you type square(2.5) into the Python console, 6.25 is returned”. These examples are called doctest examples, for a reason we’ll see in a future section. While a English description may technically be enough to specify the function’s behaviour, doctest examples are invaluable for aiding understanding of the function behaviour (which is why we use them in teaching as well!).

    The function docstring is indented inside the function header, as a visual indicator that it is part of the overall function definition. Unlike many other programming languages, this kind of indentation in Python is mandatory rather than merely recommended. Python’s designers felt strongly enough about indentation improving readability of Python programs that they put indentation requirements like this into the language itself.

  3. The final line, return x ** 2, is called the body of the function, and is the code that is executed when the function is called. Like the function docstring, the function body is also indented so that it is “inside” the function definition.

    This code uses another keyword, return, which signals a new kind of statement: the return statement, which has the form:

    return <expression>

    When a return statement is executed, the following happens:

    1. The <expression> is evaluated, producing a value.
    2. That value is then returned to wherever the function was called. No more code in the function body is executed after this point.

What happens when a function is called?

In the previous section, we called built-in functions, and took for granted that they worked properly, without worrying about how they work. Now that we’re able to define our own functions, we are ready to fully understand what happens when a function is called.

As an example, suppose we’ve defined square as above, and then call it in the Python console:

>>> square(2.5)

When we press Enter, the Python interpreter evaluates the function call by doing the following:

  1. Evaluate the argument 2.5, and then assign 2.5 to the function parameter x.
  2. Evaluate the body of the square function, by doing:
    1. First evaluate x ** 2, which is 6.25 (since x refers to the value 2.5).
    2. Then stop executing the function body, and return the value 6.25 back to the Python console.
  3. The function call square(2.5) evaluates to 6.25, and this is displayed on the screen.

As we observed in the previous section, we can combine multiple function calls within a single expression. What happens when we call square twice in the same expression? For example:

>>> square(2.5) + square(-1.0)

We can step through this as well; notice how we’ve duplicated the text from before to illustrate the similarities between calling square(2.5) and -square(-1.0).

  1. Python evaluates the operands to + in left-to-right order, so evaluate square(2.5) first.
    1. Evaluate 2.5, and then assign 2.5 to the function parameter x.
    2. Evaluate the body of the square function, by doing:
      1. First evaluate x ** 2, which is 6.25 (since x refers to 2.5).
      2. Then stop executing the function body, and return the value 6.25 back to the Python console.
  2. Nothing is displayed yet! There’s still square(-1.0) to be evaluated.
    1. Evaluate -1.0, and then assign -1.0 to the function parameter x.
    2. Evaluate the body of the square function, by doing:
      1. First evaluate x ** 2, which is 1.0 (since x refers to -1.0).
      2. Then stop executing the function body, and return the value 1.0 back to the Python console.
  3. Now the expression to evaluate has been simplified to 6.25 + 1.0, which evaluates to 7.25. This value is displayed on the screen.

Defining functions in files

While it is possible to define functions directly in the Python console, this isn’t a good approach: every time we restart the Python console, we lose all our previous definitions. So instead, we save functions in files so that we can reuse them across multiple sessions in the Python console (and in other files).

For example, suppose we have the following file called my_functions.py:

def square(x: float) -> float:
    """Return x squared.

    >>> square(3.0)
    9.0
    >>> square(2.5)
    6.25
    """
    return x ** 2

In PyCharm, we can right-click and select “Run File in Python Console”. This will start the Python console and run our file, which then allows us to call our function square just like any built-in function:

>>> square(3.0)
9.0


Defining functions with multiple parameters

Let’s now look at a more complex example that will illustrate a function definition that takes in more than one parameter.

Recall the distance formula from Section 1.4 to calculate the distance between two points \((x_1, y_1), (x_2, y_2)\) in the Cartesian plane: \[d = \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2}\]

We’ll now write a function in Python that calculates this formula. This function will take two inputs, where each input is a tuple of two floats, representing the \(x\)- and \(y\)-coordinates of each point. When we define a function with multiple parameters, we write the name and type of each parameter using the same format we saw earlier, with parameters separated by commas from each other. Here is the function header and docstring:

def calculate_distance(p1: tuple, p2: tuple) -> float:
    """Return the distance between points p1 and p2.

    p1 and p2 are tuples of the form (x, y), where the x- and y-coordinates are points.

    >>> calculate_distance((0, 0), (3.0, 4.0))
    5.0
    """

In order to use the above formula, we need to extract the coordinates from each point. This is a good reminder of tuple indexing, and the fact that function bodies can consist of more than one statement. Remember: the function body’s statements are executed one at a time until a return statement is executed.

    # The start of the body of calculate_distance
    x1 = p1[0]
    y1 = p1[1]
    x2 = p2[0]
    y2 = p2[1]

Now that we have the four coordinates, we can apply the above formula and return the result

    # Continuing the function body
    return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5

Putting this all together, we have:

def calculate_distance(p1: tuple, p2: tuple) -> float:
    """Return the distance between points p1 and p2.

    p1 and p2 are tuples of the form (x, y), where the x- and y-coordinates are points.

    >>> calculate_distance((0, 0), (3.0, 4.0))
    5.0
    """
    x1 = p1[0]
    y1 = p1[1]
    x2 = p2[0]
    y2 = p2[1]
    return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5

Function reuse

Our above function body is perfectly correct, but you might notice that the ** 2 expressions exactly mimic the body of the first function we defined in this section: square. And so we can reuse the square function inside the body of calculate_distance:

def calculate_distance(p1: tuple, p2: tuple) -> float:
    """Return the distance between points p1 and p2.

    p1 and p2 are tuples of the form (x, y), where the x- and y-coordinates are points.

    >>> calculate_distance((0, 0), (3.0, 4.0))
    5.0
    """
    x1 = p1[0]
    y1 = p1[1]
    x2 = p2[0]
    y2 = p2[1]
    return (square(x1 - x2) + square(y1 - y2)) ** 0.5

This example of function reuse is quite small, but as our programs grow larger, it will be essential to organize our code into different functions. We’ll explore this idea in more detail, and other principles of good function and program design, throughout this course.

References