4.3 Exceptions#
Right now, our stack implementation raises an unfortunate error when client code calls pop
on an empty Stack
:[1]
>>> 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 for how pop
could deal with an
inappropriate call.
Alternative: fail silently#
One simple improvement is for the method to “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
,
because if it were later popped,
it would look lik it was indicating that a problem occurred
rather than that the value None
was just popped off the stack.
There may be clients who want to be able to push None
onto a stack 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.[2]
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 Stack
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:
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:
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.
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 str | None
.
def second_from_top(s: Stack) -> str | None:
"""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.
"""
try:
# Pop and remember the top 2 items in s.
hold1 = s.pop()
except EmptyStackError:
print("Cannot return second from top, stack empty")
return None
try:
hold2 = s.pop()
except EmptyStackError:
print("Cannot return second from top, stack only has one element")
s.push(hold1)
return None
# If we've reached this poing, both hold1 and hold2 refer to items
# from the stack.
# Push them back so that s is exactly as it was.
s.push(hold2)
s.push(hold1)
# Return the item that was second from the top.
return hold2