A115 Software Engineering

Bespoke cloud-based software platforms powering UK commerce since 2010

Re-introduction to Python - part 9. Exceptions.

We have so far looked at two main mechanisms for controlling the logical flow of a Python program - conditional statements (if / elif / else) for making decisions and loops (for, while) for iterating over collections or repeatedly applying a certain sequence of operations. We've also seen, from the exercises, that these can be combined and nested into one another in arbitrary ways, depending on the complexity of what our program needs to accomplish.

Today we will learn about another flow control mechanism, which helps with handling unusual (but not entirely unexpected) situations. The technical name for such situations in Python is "exceptions" and they are best illustrated with some examples.

Let's say we are working on a function, which allocates a certain amount - like a bill to be paid - equally to n contributors (like a group of 6 heading out to the pub after lockdown and wishing to split the bill fairly). Our first attempt might look something like:

amount_to_pay = total_amount / n

This will, of course, work in all reasonable cases. But what happens when n is zero? You might ask, why would someone need to split a bill in a group of 0 people, and I honestly have no idea why, but 20+ years industry experience has taught me that people try to do things you don't want to know about sometimes.

What would happen with our current code if n was 0, is that Python would attempt to divide the amount by 0, which is not mathematically permissible, so our program will break at that point with an error (an "exception") that looks something like: ZeroDivisionError: division by zero. The first part (ZeroDivisionError) is the name of the exception - and the rest is meant to be a somewhat more human-readable description of what went wrong.

Now that we know this, we have a risk-management decision to make in our head (or with our team). We have some questions to answer. Questions like: is it OK that our program can break like this? How likely is this to happen? What would be the consequences if this did happen? What would be a better way of handling this? We would also have to consider the answers to these questions for several different points of view, such as:

  • user experience (probably wouldn't be great if our app just blows up)
  • business risk (what are the business implications of a group of 0 people not being able to split their bill? Probably aren't any in this case, I don't know, but worth thinking about.)
  • information security (if our program was to just error out like this, would that expose any potentially sensitive information to the user, or otherwise pose a security risk? That depends on the larger context.)

At any rate, if we decide we do want to handle this situation better, Python gives us something called a try / except construct for handling situations like these. What we do is we wrap the vulnerable part of the code in a "try" block and then provide further code to deal with the cases where one or more types of exceptions occur. In our case it could look something like this:

    amount_to_pay = total_amount / n
except ZeroDivisionError:
    return 0

In other words, we have had our team meeting (or the meeting in our head) and have decided that the best thing to do would be to catch this exception and pretend that a group of 0 people means that "everyone" has 0 to pay. Probably not mathematically or philosophically correct, but for the sake of pragmatism, this helps us sleep better at night at least knowing that our program won't just error out in this case and won't expose any potentially sensitive data where it shouldn't.

Another common example of a Python exception is if your function expects a list of items and you try to access a specific item by its index. Let's say you want to look at the value of the first item, and you do something very reasonably looking like this: first_item = items[0]. Now of course the question to consider is what happens if the function was passed an empty list, which doesn't have a first item. In that case, your program would error out with an IndexError. Again, it's up to you whether you want to actually process that in a safer way or not, but it's important that you are aware of this possibility and that you (or the team) make that decision consciously.

Another mechanism Python gives you is the ability to define and raise your own exceptions throughout your code. So you might, for example, wish to define an EmptyWalletException and raise it if your superhero character tries to spend more than they have.

You should keep in mind, however, that just like if statements increase the complexity of your code with each new logical branch, so too raising exceptions (whether built-in ones or custom defined) increases the complexity of your code in much the same way - by introducing new possibilities that need to be considered. My rule of thumb is: make sure you have a good reason to raise or catch exceptions. If you don't have a good reason, just let the code fail as it would.

In software engineering, there is this concept of a "pure" function. A pure function only takes its input as arguments and returns its output using a return statement. Anything else - like interacting with a user, or reading / writing data from files, or network operations, or modifying the values of variables outside its scope (e.g. global variables), or throwing exceptions - is considered "side effects".

Very often, in practice, side effects are unavoidable. We do, after all, need to interact with the external world - in fact, that's usually the whole point of a computer program. Still, software engineers are trained to try their best to keep functions "pure" as much as possible. This is because "pure" functions have this wonderful property that no matter how many times (or under what circumstances) you call them with the same arguments, they will always return the same output. This makes them easier to test and reason about. It also makes them more "reusable" in a wider range of situations.

Some questions for further research and discussion:

  1. When your program errors out with an exception, other than the exception type and the error message, Python also typically prints out multiple lines of details, trying to point out exactly where in your code this happened. What is the technical name for this printout?
  2. Are you able to identify any side-effects in any of the functions you have written so far? If so, are you able to re-write those functions to have fewer side-effects and be more "pure"?
  3. In the code you've written so far, are you able to identify any situations where exceptions might arise? How would you process those in a safer way?
  4. In the code you've written so far, are there any situations where you would want to raise your own custom exception?
  5. In the except: portion of a try / except block, how can you get the text of the original message of the exception that would have been raised?
  6. How can you catch more than one type of exception, handling each type in a different way? E.g. handle a ValueError in one way, and a KeyError in a different way?
  7. How can you catch more than one type of exception, handling each type in exactly the same way? E.g. handle both a ValueError and a KeyError with the same except: block of code, without repeating yourself?
  8. In a try / except construct, how can you have a block of code which executes after the try block but only if no exception was caught?