1.4 Preconditions#

One of the most important purposes of a function docstring is to let others know how to use the function. After all, we don’t just write code for ourselves, but for other members of our development team or company, or even the world at large if we’re writing a library we think is useful to anyone.

The docstring of a function describes not only what the function does—through text and examples—but also the requirements necessary to use the function. One such requirement is the type contract: this requires that when someone calls the function, they do so with arguments of a specified type.

For example, given this function docstring:

def decreases_at(numbers: list[int]) -> int:
    """Return the index of the first number that is less than its predecessor.

    >>> decreases_at([3, 6, 9, 12, 2, 1, 8, 5])
    4
    """

We know that decreases_at expects to be called on a list of integers; if we violate the type contract, say by calling it on a single integer or a dictionary, we cannot expect it to work properly.

In practice, we often want to extend this idea beyond specifying the required type of arguments. For example, we might want to say that “this function must be given numbers between 1 and 10” or “the first argument must be greater than the second argument.” A precondition of a function is any property that the function’s arguments must satisfy to ensure that the function works as described. They are included in a function’s docstring, and form a crucial part of the function’s interface.

As a user of a function, preconditions are extremely important, since they tell you what you have to do to use the function properly. They limit how a function can be used. On the flip side, preconditions are freeing to the implementor of a function: by specifying a certain property in a precondition, the person writing the body of the function can go ahead and assume that this property is satisfied, which often leads to a simpler or more efficient implementation. Consider a method for searching a list. Binary search is efficient, but depends on having a sorted list. If the search method had to confirm this, the added work would make it slower than linear search! In this case, it makes sense to simply require the caller to provide a sorted list.

The bottom line is that specifying preconditions is part of the design of a function. It is a matter of specifying precisely what service we want to provide to the users of our functions—and what restrictions we want to impose upon them.

How can we check preconditions?#

While our previous example illustrates how to document preconditions as part of a function specification, it has one drawback: it relies on whoever is calling the function to read the documentation! Of course, reading documentation is an important skill for any computer scientist, but despite our best intentions we sometimes miss things. It would be nice if we could turn our preconditions into executable Python code so that the Python interpreter checks them every time we call the function.

For the rest of this section, we’ll use the following function as our running example. Note that in addition to the parameter type annotation, we’ve included a precondition written in the function docstring.

def max_length(strings: list[str]) -> int:
    """Return the maximum length of a string in the given list of strings.

    Preconditions:
      - strings != []
    """
    max_so_far = -1
    for s in strings:
        if len(s) > max_so_far:
            max_so_far = len(s)

    return max_so_far

Checking preconditions with assertions#

One way to do this is to use an assert statement. Because we’ve written the precondition as a Python expression, we can convert this to an assertion by copy-and-pasting it at the top of the function body.

def max_length(strings: list[str]) -> int:
    """Return the maximum length of a string in the given list of strings.

    Preconditions:
      - strings != []
    """
    assert strings != []  # Check the precondition

    max_so_far = -1
    for s in strings:
        if len(s) > max_so_far:
            max_so_far = len(s)

    return max_so_far

Now, the precondition is checked every time the function is called, with a meaningful error message when the precondition is violated:

>>> empty_list = []
>>> max_length(empty_list)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 7, in max_length
AssertionError

We can even improve the error message we get by using an extended syntax for assert statements, where we include a string message to display after the boolean expression being checked:

def max_length(strings: list[str]) -> int:
    """Return the maximum length of a string in the given list of strings.

    Preconditions:
      - strings != []
    """
    assert strings != [], 'Precondition violated: max_length called on an empty list.'

    max_so_far = -1
    for s in strings:
        if len(s) > max_so_far:
            max_so_far = len(s)

    return max_so_far

Calling max_length on an empty set raises the same AssertionError as before, but now displays a more informative error message:

>>> empty_list = []
>>> max_length(empty_list)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 7, in max_length
AssertionError: Precondition violated: max_length called on an empty list.

However, this approach of copy-and-pasting preconditions into assertions is tedious and error-prone. First, we have to duplicate the precondition in two places. And second, we have increased the size of the function body with extra code. And worst of all, both of these problems increase with the number of preconditions! There must be a better way.

Enter PythonTA#

The python_ta library we use in this course has a way to automatically check preconditions for all functions in a given file. Here is an example:

from python_ta.contracts import check_contracts  # NEW


@check_contracts  # NEW
def max_length(strings: list[str]) -> int:
    """Return the maximum length of a string in the given list of strings.

    Preconditions:
      - strings != []
    """
    max_so_far = -1
    for s in strings:
        if len(s) > max_so_far:
            max_so_far = len(s)

    return max_so_far

Notice that we’ve kept the function docstring the same, but removed the assertion. Instead, we are importing a new module (python_ta.contracts), and then using the check_contracts from that module as a… what? 🤔

The syntax @check_contracts is called a decorator, and is technically a form of syntax that is an optional part of a function definition that goes immediately above the function header. We say that the line @check_contracts decorates the function max_length, which means that it adds additional behaviour to the function beyond what is written the function body.

So what is this “additional behaviour” added by check_contracts? As you might imagine, it reads the function’s type contract and the preconditions written in the function docstring, and causes the function to check these preconditions every time max_length is called. Let’s see what happens when we run this file in the Python console, and attempt to call max_length on an empty set:

>>> empty_list = []
>>> max_length(empty_list)
Traceback (most recent call last):
  ...  # File location details omitted
AssertionError: max_length precondition "strings != []" was violated for arguments {strings: []}

Pretty cool! And moreover, because all parameter type annotations are preconditions, python_ta will also raise an error if an argument does not match a type annotation. Here’s an example of that:

>>> max_length(148)
Traceback (most recent call last):
  ...  # File location details omitted
AssertionError: max_length argument 148 did not match type annotation for parameter strings: list[str]

We’ll be using PythonTA’s check_contracts decorator throughout this course to help us make sure we’re sticking to the specifications we’ve written in our function header and docstrings when we call our functions. Moreover, check_contracts checks the return type of each function, so it’ll also work as a check when we’re implementing our functions to make sure the return value is of the correct type.