5.3 Why Not Just Return a Special Value?

5.3 Why Not Just Return a Special Value?#

You may be thinking that all of this exception stuff seems a little complicated. Why don’t we just return a special value to indicate that there was a problem? There are two very good reasons.

Exceptions yield code that is more robust#

If we return a special value, the code that called the method or function can ignore the problem. It shouldn’t, but it can. This may cause a problem later. This simply can’t happen with exceptions, because an exception cannot be ignored. If an exception is never caught and handled, it will crash the program and the exception will be printed. This is very unsatisfying for the user, but much preferrable to the problem being ignored and the program continuing to run and perhaps producing incorrect results, with no warning to the user that this has happened!

Exceptions yield cleaner code#

What if we want to use the approach of returning a special value and are willing to be more careful than this? If the calling code isn’t going to ignore the problem, it has to do some work. At the very least, it must notice that a special value was returned, and return a special value to its caller. Its caller must do the same, and its caller, and so on. If instead our function raises an exception, all the handling code can be located in one spot (or fewer spots), assuming that the same steps for handling the exception are suitable. As long as somewhere on the call stack there is guaranteed to be a function that will catch and handle the exception, none of the other methods or functions have to.

A realistic example will help make this concrete. The program below reads lists of numbers from a file and reports how many of those lists represent a “magic square” (a square all of whose rows and all of whose columns add up to the same number). The code itself runs properly if the input file has appropriate contents. But many things can go wrong if it doesn’t, and this can result in exceptions being raised. This version of the code just lets that happen, although it does take care to document this behaviour. Read the docstrings to see what what sorts of exceptions can occur where.

# Version 1: Let exceptions happen

def fill_matrix(numbers: list[int], n: int) -> list[list[int]]:
    """Return a matrix with values from <numbers>. Each row in the matrix will
    have n items, except the last line, which may be shorter.

    Precondition: n >= 1

    >>> stuff = [1, 2, 3, 4, 5, 6, 7]
    >>> m = fill_matrix(stuff, 3)
    >>> m
    [[1, 2, 3], [4, 5, 6], [7]]
    """
    answer = []
    i = 0   # An index into <numbers>.
    while i < len(numbers):
        next_row = []
        c = 0   # The logical column number corresponding to numbers[i]
        while c < n and i < len(numbers):
            next_row.append(int(numbers[i]))
            i += 1
            c += 1
        answer.append(next_row)
    return answer


def row_sum(m: list[list[int]], r: int) -> int:
    """Return the sum of all values in row <r> of matrix <m>.

    Raise an IndexError if <r> is not a valid row of <m>.

    >>> m = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    >>> row_sum(m, 1)
    15
    """
    total = 0
    for i in range(len(m[0])):
        total += m[r][i]
    return total


def col_sum(m: list[list[int]], c: int) -> int:
    """Return the sum of all values in row <r> of matrix <m>.

    Raise an IndexError if <c> is not a valid column in each row of <m>.

    >>> m = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    >>> col_sum(m, 2)
    18
    """
    total = 0
    for i in range(len(m[0])):
        total += m[i][c]
    return total


def is_magic(m: list[list[int]]) -> bool:
    """Return whether <m> is a magic square.

    Raise an IndexError if <m> is not a square matrix.

    >>> is_magic([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
    False
    >>> is_magic([[5, 5, 5], [5, 5, 5], [5, 5, 5]])
    True
    """
    first_row = row_sum(m, 0)
    for i in range(len(m[0])):
        total = row_sum(m, i)
        if total != first_row:
            return False
        c = col_sum(m, i)
        if c != first_row:
            return False
    return True


def num_magic(filename: str, n: int) -> int:
    """Return the number of magic squares in the file with name <filename>.

    Raise an IndexError if one or more input lines does not have n x n items.
    Raise a FileNotFoundError if there is no such file.
    Raise a ValueError if the file contains values that are not integers.
    """
    count = 0

    with open(filename) as infile:
        for line in infile:
            items = line.strip().split()
            nums = [int(s) for s in items]  # Uses a "list comprehension"
            m = fill_matrix(nums, n)
            if is_magic(m):
                count += 1

    return count


if __name__ == '__main__':
    num = num_magic('numbers.txt', 3)
    print(num)

If we run this program and the file doesn’t exist, or one of its lines does not contain enough numbers to fill a 3-by-3 matrix, or it has anything in it that can’t be interpreted as an int, then an exception will be raised, the stack will be cleared, and the user will see the exception.

Suppose we want to do better, but without having to catch exceptions. We can have our functions return a special value instead. Here is a version of the program that takes this approach.[1]

# Version 2: Return special values instead

def fill_matrix(numbers: list[int], n: int) -> list[list[int]]:
    """Return a matrix with values from <numbers>. Each row in the matrix will
    have n items, except the last line, which may be shorter.

    Precondition: n >= 1

    >>> stuff = [1, 2, 3, 4, 5, 6, 7]
    >>> m = fill_matrix(stuff, 3)
    >>> m
    [[1, 2, 3], [4, 5, 6], [7]]
    """
    answer = []
    i = 0   # An index into <numbers>.
    while i < len(numbers):
        next_row = []
        c = 0   # The logical column number corresponding to numbers[i]
        while c < n and i < len(numbers):
            next_row.append(int(numbers[i]))
            i += 1
            c += 1
        answer.append(next_row)
    return answer


def row_sum(m: list[list[int]], r: int) -> int:
    """Return the sum of all values in row <r> of matrix <m>,
    or -1 if <r> is not a valid row of <m>.

    >>> m = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    >>> row_sum(m, 1)
    15
    """
    if not (0 <= r < len(m)):
        return -1
    else:
        total = 0
        for i in range(len(m[0])):
            total += m[r][i]
        return total


def col_sum(m: list[list[int]], c: int) -> int:
    """Return the sum of all values in row <r> of matrix <m>,
    or -1 if <c> is not a valid column in each row of <m>.

    >>> m = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    >>> col_sum(m, 2)
    18
    """
    total = 0
    for i in range(len(m[0])):
        if not 0 <= c < len(m[i]):
            return -1
        else:
            total += m[i][c]
    return total


def is_magic(m: list[list[int]]) -> bool | None:
    """Return a bool indicating whether or not <m> is a magic square,
    or None if <m> is not a square matrix.

    >>> is_magic([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
    False
    >>> is_magic([[5, 5, 5], [5, 5, 5], [5, 5, 5]])
    True
    """
    first_row = row_sum(m, 0)
    for i in range(len(m[0])):
        total = row_sum(m, i)
        if total == -1:
            return None
        else:
            if total != first_row:
                return False
            c = col_sum(m, i)
            if c == -1:
                return None
            else:
                if c != first_row:
                    return False
    return True


def num_magic(filename: str, n: int) -> int:
    """Return the number of magic squares in the file with name <filename>.

    Raise a FileNotFoundError if there is no such file.
    Raise a ValueError if the file contains values that are not integers.
    """
    count = 0

    with open(filename) as infile:
        for line in infile:
            items = line.strip().split()
            nums = [int(s) for s in items]  # Uses a "list comprehension"
            m = fill_matrix(nums, n)
            if is_magic(m):
                count += 1

    return count


if __name__ == '__main__':
    num = num_magic('numbers.txt', 3)
    print(num)

Notice that many docstrings have changed: instead of saying that the function raises an exception, they say that a special value is returned in a certain case. We also changed the type contract for is_magic to allow for a special value (None).

You may notice that this code is more cumbersome. Both row_sum and col_sum have to check for a valid index because they both are susceptible to that problem. And is_magic has to do so as well, since it calls these and could receive a special value from them. All three functions are dealing with the same sort of problem, and the code is repetitive. And the extra logic to check for problems and return special values also obfuscates the “normal” case.

If we use exceptions, we can gather all the checking into one place. Here we’ve gathered it into num_magic:

# Version 3: Catch exceptions

def fill_matrix(numbers: list[int], n: int) -> list[list[int]]:
    """Return a matrix with values from <numbers>. Each row in the matrix will
    have n items, except the last line, which may be shorter.

    Precondition: n >= 1

    >>> stuff = [1, 2, 3, 4, 5, 6, 7]
    >>> m = fill_matrix(stuff, 3)
    >>> m
    [[1, 2, 3], [4, 5, 6], [7]]
    """
    answer = []
    i = 0   # An index into <numbers>.
    while i < len(numbers):
        next_row = []
        c = 0   # The logical column number corresponding to numbers[i]
        while c < n and i < len(numbers):
            next_row.append(int(numbers[i]))
            i += 1
            c += 1
        answer.append(next_row)
    return answer


def row_sum(m: list[list[int]], r: int) -> int:
    """Return the sum of all values in row <r> of matrix <m>.

    Raise an IndexError if <r> is not a valid row of <m>.

    >>> m = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    >>> row_sum(m, 1)
    15
    """
    total = 0
    for i in range(len(m[0])):
        total += m[r][i]
    return total


def col_sum(m: list[list[int]], c: int) -> int:
    """Return the sum of all values in row <r> of matrix <m>.

    Raise an IndexError if <c> is not a valid column in each row of <m>.

    >>> m = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    >>> col_sum(m, 2)
    18
    """
    total = 0
    for i in range(len(m[0])):
        total += m[i][c]
    return total


def is_magic(m: list[list[int]]) -> bool:
    """Return a bool indicating whether or not <m> is a magic square.

    Raise an IndexError if m is not a square matrix.

    >>> is_magic([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
    False
    >>> is_magic([[5, 5, 5], [5, 5, 5], [5, 5, 5]])
    True
    """
    first_row = row_sum(m, 0)
    for i in range(len(m[0])):
        total = row_sum(m, i)
        if total != first_row:
            return False
        c = col_sum(m, i)
        if c != first_row:
            return False
    return True


def num_magic(filename: str, n: int) -> int:
    """Return the number of magic squares in the file with name <filename>.
    """
    try:
        count = 0

        with open(filename) as infile:
            for line in infile:
                items = line.strip().split()
                nums = [int(s) for s in items]  # Uses a "list comprehension"
                m = fill_matrix(nums, n)
                if is_magic(m):
                    count += 1

        return count
    except IndexError:
        print(f'Warning: One or more input lines did not have {n}x{n} items.')
        return count
    except FileNotFoundError:
        print(f'File {filename} does not exist.')
        return 0
    except ValueError:
        print('Warning: One or more input lines had invalid data.')
        return count


if __name__ == '__main__':
    num = num_magic('numbers.txt', 3)
    print(num)

This code is much cleaner and easier to read because:

  • Functions row_sum, col_sum and is_magic can ignore potential problems and focus on their jobs (just being sure to document the exceptions they may raise).

  • We only have to deal with potential IndexErrors in one place, function num_magic.

While we were revising num_magic to handle that exception, we added code to handle the two other kinds of exceptions that could occur in this function. We removed the notice in the docstring about exceptions that could be raised, since this version of the function does not raise any of these exceptions.

One last note: there are other places we could have put the exception handlers. For example, we could have handled the IndexErrors in is_magic, leaving num_magic to handle only exceptions of type FileNotFoundError and ValueError. Or we could have put all the exception handling in the main block. There are many options, and these are design decisions.