Home > Articles

Functions

This chapter is from the book

Item 32: Prefer Raising Exceptions to Returning None

When writing utility functions, there’s a draw for Python programmers to give special meaning to the return value None. It seems to make sense in some cases (see Item 26: “Prefer get over in and KeyError to Handle Missing Dictionary Keys”). For example, say I want a helper function that divides one number by another. In the case of dividing by zero, returning None seems natural because the result is undefined:

def careful_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

Code that uses this function can interpret the return value accordingly:

x, y = 1, 0
result = careful_divide(x, y)
if result is None:
    print("Invalid inputs")

What happens with the careful_divide function when the numerator is zero? If the denominator is not zero, then the function returns zero. The problem is that a zero return value can cause issues when you evaluate the result in a condition like an if statement. You might accidentally look for any falsey value to indicate errors instead of only looking for None (see Item 4: “Write Helper Functions Instead of Complex Expressions” and Item 7: “Consider Conditional Expressions for Simple Inline Logic”):

x, y = 0, 5
result = careful_divide(x, y)
if not result:               # Changed
    print("Invalid inputs")  # This runs! But shouldn't

>>>
Invalid inputs

This misinterpretation of a False-equivalent return value is a common mistake in Python code when None has special meaning. This is why returning None from a function like careful_divide is error prone. There are two ways to reduce the chance of such errors.

The first way is to split the return value into a two-tuple (see Item 31: “Return Dedicated Result Objects Instead of Requiring Function Callers to Unpack More Than Three Variables” for background). The first part of the tuple indicates that the operation was a success or failure. The second part is the actual result that was computed:

def careful_divide(a, b):
    try:
        return True, a / b
    except ZeroDivisionError:
        return False, None

Callers of this function have to unpack the tuple. That forces them to consider the status part of the tuple instead of just looking at the result of division:

success, result = careful_divide(x, y)
if not success:
    print("Invalid inputs")

The problem is that callers can easily ignore the first part of the tuple (using the underscore variable name, which is a Python convention for unused variables). The resulting code doesn’t look wrong at first glance, but this can be just as error prone as returning None:

_, result = careful_divide(x, y)
if not result:
    print("Invalid inputs")

The second, better way to reduce these errors is to never return None for special cases. Instead, raise an exception up to the caller and have the caller deal with it. Here, I turn ZeroDivisionError into ValueError to indicate to the caller that the input values are bad (see Item 88: “Consider Explicitly Chaining Exceptions to Clarify Tracebacks” and Item 121: “Define a Root Exception to Insulate Callers from APIs” for details):

def careful_divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        raise ValueError("Invalid inputs")  # Changed

The caller no longer requires a condition on the return value of the function. Instead, it can assume that the return value is always valid and use the results immediately in the else block after try (see Item 80: “Take Advantage of Each Block in try/except/else/finally” for background):

x, y = 5, 2
try:
    result = careful_divide(x, y)
except ValueError:
    print("Invalid inputs")
else:
    print(f"Result is {result:.1f}")

>>>
Result is 2.5

This approach can be extended to code using type annotations (see Item 124: “Consider Static Analysis via typing to Obviate Bugs” for background). You can specify that a function’s return value will always be a float and thus will never be None. However, Python’s gradual typing purposely doesn’t provide a way to indicate when exceptions are part of a function’s interface (also known as checked exceptions). Instead, you have to document the exception-raising behavior and expect callers to rely on that in order to know which exceptions they should plan to catch (see Item 118: “Write Docstrings for Every Function, Class, and Module”).

Pulling it all together, here’s what this function should look like when using type annotations and docstrings:

def careful_divide(a: float, b: float) -> float:
    """Divides a by b.

    Raises:
        ValueError: When the inputs cannot be divided.
    """
    try:
        return a / b
    except ZeroDivisionError:
        raise ValueError("Invalid inputs")

try:
    result = careful_divide(1, 0)
except ValueError:
    print("Invalid inputs")  # Expected
else:
    print(f"Result is {result:.1f}")

>>>
$ python3 -m mypy --strict example.py
Success: no issues found in 1 source file

Now the inputs, outputs, and exceptional behavior are all clear, and the chance of a caller doing the wrong thing is extremely low.

Things to Remember

  • Functions that return None to indicate special meaning are error prone because None and many other values, such as zero and empty strings, evaluate to False in Boolean expressions.

  • Raise exceptions to indicate special situations instead of returning None. Expect the calling code to handle exceptions properly when they’re documented.

  • Type annotations can be used to make it clear that a function will never return the value None, even in special situations.

InformIT Promotional Mailings & Special Offers

I would like to receive exclusive offers and hear about products from InformIT and its family of brands. I can unsubscribe at any time.