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
andis_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
IndexError
s in one place, functionnum_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 IndexError
s 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.