Handling Exceptions in Python Object-Oriented Programming
Systems built with software can be fragile. While the software is highly predictable, the runtime context can provide unexpected inputs and situations. Devices fail, networks are unreliable, mere anarchy is loosed on our application. We need to have a way to work around the spectrum of failures that plague computer systems.
This article is an excerpt taken from a chapter of the book Python Object-Oriented Programming, Fourth Edition by Steven F. Lott and Dusty Phillips — a comprehensive guide to exploring modern Python through data structures, design patterns, and effective object-oriented techniques.
There are two broad approaches to dealing with the unforeseen. One approach is to return a recognizable error-signaling value from a function. A value, like None, could be used. Other library functions can then be used by an application to retrieve details of the erroneous condition. A variation on this theme is to pair a return from an OS request with a success or failure indicator. The other approach is to interrupt the normal, sequential execution of statements and divert to statements that handle exceptions. This second approach is what Python does: it eliminates the need to check return values for errors.
Handling exceptions in Python Object-Oriented Programming
If we encounter an exception situation, how should our code react to or recover from it? We handle exceptions by wrapping any code that might throw one (whether it is exception code itself, or a call to any function or method that may have an exception raised inside it) inside a try…except clause. The most basic syntax looks like this:
def handler() -> None:
try:
never_returns()
print("Never executed")
except Exception as ex:
print(f"I caught an exception: {ex!r}")
print("Executed after the exception")
If we run this simple script using our existing never_returns() function — which, as we know very well, always throws an exception — we get this output:
I am about to raise an exception
I caught an exception: Exception('This is always raised')
Executed after the exception
The never_returns() function happily informs us that it is about to raise an exception and raises it. The handler() function’s except clause caught the exception. Once caught, we were able to clean up after ourselves (in this case, by outputting that we were handling the situation), and continue on our way. The remainder of the code in the never_returns() function remains unexecuted, but the code in the handler() function after the try: statement was able to recover and continue.
Note the indentation around try and except. The try clause wraps any code that might throw an exception. The except clause is then back on the same indentation level as the try line. Any code to handle the exception is indented inside the except clause. Then normal code resumes at the original indentation level.
The problem with the preceding code is that it uses the Exception class to match any type of exception. What if we were writing some code that could raise either TypeError or ZeroDivisionError? We might need to catch ZeroDivisionError because it reflects a known object state, but let any other exceptions propagate to the console because they reflect bugs we need to catch and kill. Can you guess the syntax?
Here’s a rather silly function that does just that:
from typing import Union
def funny_division(divisor: float) -> Union[str, float]:
try:
return 100 / divisor
except ZeroDivisionError:
return "Zero is not a good idea!"
This function does a simple computation. We’ve provided the type hint of float for the divisor parameter. We can provide an integer, and ordinary Python type coercion will work. The mypy tool is aware of the ways integers can be coerced to floats, saving it from having to obsess over the parameter types.
We do, however, have to be very clear about the return types. If we don’t raise an exception, we’ll compute and return a floating result. If we do raise a ZeroDivisionError exception, it will be handled, and we’ll return a string result. Any other exceptions? Let’s try it and see.
>>> print(funny_division(0))
Zero is not a good idea!
>>> print(funny_division(50.0))
2.0
>>> print(funny_division("hello"))
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for /: 'int' and 'str'
The first line of output shows that if we enter 0, we get properly mocked. If we call with a valid number, it operates correctly. Yet if we enter a string (you were wondering how to get a TypeError, weren’t you?), it fails with an unhandled exception. If we don’t specify matching the ZeroDivisionError exception class, our handler would also see the TypeError, and accuse us of dividing by zero when we sent it a string, which is not a proper behavior at all.
Python also has a bare except syntax. Using except: with no exception class to match is widely frowned upon because it will prevent an application from simply crashing when it should. We generally use except Exception: to explicitly catch a sensible set of exceptions.
The bare except syntax is actually the same as using except BaseException:, which attempts to handle system-level exceptions that are often impossible to recover from. Indeed, this can make it impossible to crash your application when it’s misbehaving.
We can even catch two or more different exceptions and handle them with the same code. Here’s an example that raises three different types of exceptions. It handles TypeError and ZeroDivisionError with the same exception handler, but it may also raise a ValueError error if you supply the number 13:
def funnier_division(divisor: int) -> Union[str, float]:
try:
if divisor == 13:
raise ValueError("13 is an unlucky number")
return 100 / divisor
except (ZeroDivisionError, TypeError):
return "Enter a number other than zero"
We’ve included multiple exception classes in the except clause. This lets us handle a variety of conditions with a common handler. Here’s how we can test this with a bunch of different values.
>>> for val in (0, "hello", 50.0, 13):
... print(f"Testing {val!r}:", end=" ")
... print(funnier_division(val))
...
Testing 0: Enter a number other than zero
Testing 'hello': Enter a number other than zero
Testing 50.0: 2.0
Testing 13: Traceback (most recent call last):
File "<input>", line 3, in <module>
File "<input>", line 4, in funnier_division
ValueError: 13 is an unlucky number
The for statement iterates over several test inputs and prints the results. If you’re wondering about that end parameter in the print function, it just turns the default trailing newline into a space so that it’s joined with the output from the next line.
The number 0 and the string are both caught by the except clause, and a suitable error message is printed. The exception from the number 13 is not caught because it is a ValueError, which was not included in the types of exceptions being handled. This is all well and good, but what if we want to catch different exceptions and do different things with them? Or maybe we want to do something with an exception and then allow it to continue to bubble up to the parent function, as if it had never been caught?
We don’t need any new syntax to deal with these cases. It’s possible to stack the except clauses, and only the first match will be executed. For the second question, the raise keyword, with no arguments, will re-raise the last exception if we’re already inside an exception handler. Observe the following code:
def funniest_division(divisor: int) -> Union[str, float]: try:
if divider == 13:
raise ValueError("13 is an unlucky number")
return 100 / divider
except ZeroDivisionError:
return "Enter a number other than zero"
except TypeError:
return "Enter a numerical value"
except ValueError:
print("No, No, not 13!")
raise
The last line re-raises the ValueError error, so after outputting No, No, not 13!, it will raise the exception again; we’ll still get the original stack trace on the console.
If we stack exception clauses like we did in the preceding example, only the first matching clause will be run, even if more than one of them fits. How can more than one clause match? Remember that exceptions are objects, and can therefore be subclassed. As we’ll see in the next section, most exceptions extend the Exception class (which is itself derived from BaseException). If we have an except clause to match Exception before we match TypeError, then only the Exception handler will be executed, because TypeError is an Exception by inheritance.
This can come in handy in cases where we want to handle some exceptions specifically, and then handle all remaining exceptions as a more general case. We can list Exception in its own clause after catching all the specific exceptions and handle the general case there.
Often, when we catch an exception, we need a reference to the Exception object itself. This most often happens when we define our own exceptions with custom arguments, but can also be relevant with standard exceptions. Most exception classes accept a set of arguments in their constructor, and we might want to access those attributes in the exception handler. If we define our own Exception class, we can even call custom methods on it when we catch it. The syntax for capturing an exception as a variable uses the as keyword:
>>> try:
... raise ValueError("This is an argument")
... except ValueError as e:
... print(f"The exception arguments were {e.args}")
...
The exception arguments were ('This is an argument',)
When we run this simple snippet, it prints out the string argument that we passed into ValueError upon initialization.
We’ve seen several variations on the syntax for handling exceptions, but we still don’t know how to execute code regardless of whether or not an exception has occurred. We also can’t specify code that should be executed only if an exception does not occur. Two more keywords, finally and else, provide some additional execution paths. Neither one takes any extra arguments.
We’ll show an example with the finally clause. For the most part, we often use context managers instead of exception blocks as a cleaner way to implement a finalization that occurs whether or not an exception interrupted processing. The idea is to encapsulate responsibility for finalization to the context manager.
The following example iterates through a number of exception classes, raising an instance of each. Then some not-so-complicated exception handling code runs that illustrates the newly introduced syntax:
some_exceptions = [ValueError, TypeError, IndexError, None]
for choice in some_exceptions:
try:
print(f"\nRaising {choice}")
if choice:
raise choice("An error")
else:
print("no exception raised")
except ValueError:
print("Caught a ValueError")
except TypeError:
print("Caught a TypeError")
except Exception as e:
print(f"Caught some other error: {e.__class__.__name__}")
else:
print("This code called if there is no exception")
finally:
print("This cleanup code is always called")
If we run this example — which illustrates almost every conceivable exception handling scenario — we’ll see the following output:
(CaseStudy39) % python ch_04/src/all_exceptions.py Raising <class 'ValueError'>
Caught a ValueError
This cleanup code is always called Raising <class 'TypeError'>
Caught a TypeError
This cleanup code is always called Raising <class 'IndexError'>
Caught some other error: IndexError
This cleanup code is always called Raising None
no exception raised
This code called if there is no exception
This cleanup code is always called
Note how the print statement in the finally clause is executed no matter what happens. This is one way to perform certain tasks after our code has finished running (even if an exception has occurred). Some common examples include the following:
- Cleaning up an open database connection
- Closing an open file
- Sending a closing handshake over the network
While obscure, the finally clause is executed after the return statement inside a try clause. While this can be exploited for post-return processing, it can also be confusing to folks reading the code.
Also, pay attention to the output when no exception is raised: both the else and the finally clauses are executed. The else clause may seem redundant, as the code that should be executed when no exception is raised could just be placed after the entire try…except block. The difference is that the else block will not be executed if an exception is caught and handled. We’ll see more on this when we discuss using exceptions as flow control later.
Any of the except, else, and finally clauses can be omitted after a try block (although else by itself is invalid). If you include more than one, the except clauses must come first, then the else clause, with the finally clause at the end. You must be sure the order of the except clauses has classes that move from most specific subclasses to most generic superclasses.
Summary
In this article, we went into the details of handling exceptions. There is a wide range of core concepts that the book covers in detail including raising, defining, and manipulating exceptions. Exceptions are a powerful way to communicate unusual circumstances or error conditions without requiring a calling function to explicitly check return values. There are many built-in exceptions and raising them is trivially easy. There are several different syntaxes for handling different exception events. Learn more in the book Python Object-Oriented Programming, Fourth Edition by Steven F. Lott and Dusty Phillips.
About the Authors on Python Object-Oriented Programming
Steven F. Lott has been programming since the 70s, when computers were large, expensive, and rare. As a contract software developer and architect, he has worked on hundreds of projects, from very small to very large. He’s been using Python to solve business problems for almost 20 years.
Dusty Phillips is a Canadian software developer and an author currently living in New Brunswick. He has been active in the open-source community for 2 decades and has been programming in Python for nearly as long. He holds a master’s degree in computer science and has worked for Facebook, the United Nations, and several startups.
Editor’s note:
Learn more about Python for Data Science with Ai+ and ODSC Events
With the upcoming Ai+ Training session on October 12th, “Introduction to Python Programming,” you’ll be able to understand the object model of Python. You will familiarize yourselves with basic data structures, build functions, and classes to aid in data science work. Upon the completion of the course, you will be able to write and read Python code as well as being aware of some of the idioms that are unique to Python.
If you’re interested in a conference setting across multiple days, then these sessions coming to ODSC West 2021 this November that feature Python may be right for you:
- Applications of Modern Survival Modeling with Python: Brian Kent, PhD | Data Scientist/Founder | The Crosstab Kite
- Build a Question Answering System using DistilBERT in Python: Jayeeta Putatunda | Data Scientist | MediaMath
- Identifying Deepfake Images and Videos Using Python with Keras: Noah Giansiracusa, PhD | Assistant Professor of Mathematics/Data Science | Bentley University
- Introduction to Scikit-learn: Machine learning in Python: Thomas Fan | Senior Software Engineer | Quansight Lab
- Introduction to DL-based Natural Language Processing using TensorFlow and PyTorch: Magnus Ekman, PhD | Director | NVIDIA
Read more data science articles on OpenDataScience.com, including tutorials and guides from beginner to advanced levels! Subscribe to our weekly newsletter here and receive the latest news every Thursday. You can also get data science training on-demand wherever you are with our Ai+ Training platform.