3.3 Exceptions

Right now, our stack implementation raises an unfortunate error when client code calls pop on an empty Stack: Why is this bad from the client code’s perspective?

>>> s = Stack()
>>> s.pop()
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "...", line 58, in pop
    return self._items.pop()
IndexError: pop from empty list

Let’s look at some alternatives.

Alternative: fail silently

One simple improvement is to make the code “fail silently”, making sure to document this behaviour in the method docstring:

    def pop(self) -> Any:
        """Remove and return the element at the top of this stack.

        Do nothing if this stack is empty.

        >>> s = Stack()
        >>> s.push('hello')
        >>> s.push('goodbye')
        >>> s.pop()
        'goodbye'
        """
        if not self.is_empty():
            return self._items.pop()

Because the client code in this case expects a value to be returned, it could use the “no return value” as a sign that something bad happened. However, this approach doesn’t work for all methods; for example, push never returns a value, not even when all goes well, so failing silently would not alert the client code to a problem until potentially hundreds of lines of code later. And in pop, which does return a value, if we treat None as an indication of an error we can never allow client code to push the value None. There may be clients who want to be able to do that, and to recognize it as a legitimate value when it is popped off again.

Alternative: Raise a user-defined exception

A better solution is to raise an error when something has gone wrong, so that the client code has a clear signal. We want the errors to be descriptive, yet not to reveal any implementation details. We can achieve this very easily in Python: we define our own type of error by making a subclass of a built-in class called Exception. For example, here’s how to define our own kind of Exception called EmptyStackError:

class EmptyStackError(Exception):
    """Exception raised when calling pop on an empty stack."""
    pass

We call this a user-defined exception.

Here’s how we’ll use EmptyStackError in our pop method:

    def pop(self) -> Any:
        """Remove and return the element at the top of this stack.

        Raise an EmptyStackError if this stack is empty.

        >>> s = Stack()
        >>> s.push('hello')
        >>> s.push('goodbye')
        >>> s.pop()
        'goodbye'
        """
        if self.is_empty():
            raise EmptyStackError
        else:
            return self._items.pop()

>>> s = Stack()
>>> s.pop()
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "...", line 60, in pop
    raise EmptyStackError
EmptyStackError

When we want an EmptyStackError to happen, we construct an instance of that new class and raise it. We have already seen the raise keyword in the context of unimplemented methods in abstract superclasses. It turns out that this mechanism is very flexible, and can be used anywhere in our code to raise exceptions, even ones that we’ve defined ourselves.

Notice that the line which is shown to the client is just this simple raise statement; it doesn’t mention any implementation details of the class. And it specifies that an EmptyStackError was the problem. Defining and raising our own errors enables us to give descriptive messages to the user when they have used our class incorrectly.

Customizing the error message

One current limitation of the above approach is that simply the name of the error class is not necessarily enough to convey a user-friendly error message. We can change this by overriding the inherited __str__ method in our class:

class EmptyStackError(Exception):
    """Exception raised when calling pop on an empty stack."""

    def __str__(self) -> str:
        """Return a string representation of this error."""
        return 'You called pop on an empty stack. :('


>>> s = Stack()
>>> s.pop()
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "...", line 60, in pop
    raise EmptyStackError
EmptyStackError: You called pop on an empty stack. :(

Exceptions interrupt the normal flow of control

The normal flow of control in a program involves pushing a stack frame whenever a function is called, and popping the current (top) stack frame when we reach a return or reach the end of the function/method. When an exception is raised, something very different happens: immediately, the function ends and its stack frame is popped, sending the exception back to the caller, which in turn ends immediately, sending the exception back to its caller, and so on until the stack is empty. At that point, an error message specifying the exception is printed, and the program stops.

In fact, when this happens, much more information is printed. For every stack frame that is popped, there was a function/method that had been running and was at a particular line. The output shows both the line number and line of code. For example, here is a module that defines two useful methods and then a very silly one, mess_about, whose sole purpose is to demonstrate how exceptions work:

Code with line numbers.

Because mess_about clears the stack, the call to second_from_top is guaranteed to fail when it tries to pop even one thing from the stack. At the moment of failure, we are executing pop, and beneath it on the call stack are second_from_top, mess_about, and the main block of the module, all on pause and waiting to finish their work. When pop raises an EmptyStackError, we see a full report:

Code with line numbers.

You have undoubtedly seen this kind of error report many times. Now you should be able to use it as a treasure trove of information about what went wrong.

Handling exceptions more elegantly

Your code can be written in a way that takes responsibility for “catching” and handling exceptions. Catching an exception and taking an appropriate action instead of allowing your code to crash is a much more elegant way of dealing with errors because it shields the user from seeing errors that they should never see, and allows the program to continue.

Although we will go through a few examples to give you an idea of how to catch and handle exceptions, please make sure to read the python documentation on exceptions. You should carefully read the sections on handling exceptions and defining clean-up actions.

Consider a simple example of asking for input from the user in the form of an integer number, and testing if the number is a divisor of 42. We need to make sure that the input is well-formed. That means that we should make sure that it is indeed an integer, as well as check that the number is not going to result in a division by zero. Here is an example of how to catch and handle exceptions in a graceful way in this context:

if __name__ == '__main__':
    option = 'y'
    while option == 'y':
        value = input('Give me an integer to check if it is a divisor of 42: ')
        try:
            is_divisor = (42 % int(value) == 0)
            print(is_divisor)
        except ZeroDivisionError:
            print("Uh-oh, invalid input: 0 cannot be a divisor of any number!")
        except ValueError:
            print("Type mismatch, expecting an integer!")
        finally:
            print("Now let's try another number...")
        option = input('Would you like to continue (y/n): ')

In the context of our stack, we can similarly handle an EmptyStackError in a graceful manner. We do not necessarily have to print a message to the user (although we do in the code below), but we must document this exceptional circumstance in the docstring and we must change the return type from a str to Optional[str].

def second_from_top(s: Stack) -> Optional[str]:
    """Return a reference to the item that is second from the top of s.
    Do not change s.
    
    If there is no such item in the Stack, returns None.
    """

    hold2 = None
    try:
        # Pop and remember the top 2 items in s.
        hold1 = s.pop()
        hold2 = s.pop()
        # Push them back so that s is exactly as it was.
        s.push(hold2)
        s.push(hold1)
        # Return a reference to the item that was second from the top.
    except EmptyStackError:
        print("Cannot return second from top, stack empty")
    return hold2