When things can go wrong

When things can go wrong#

We’ve faced a few situations before, where code was not bullet proof in all cases. And this is of course a common problem in all programming languages. You as the author of the code for an algorithm has a certain workflow in mind which solves your specific problem. Also you will almost certainly design your algorithm for a specific data type. You might also be well beware of the few pitfalls that can happen when parameters or input data would let your algorithm behave abnormally. But you would of course circumvent these issues by using the algorithm “correctly”.

What if though, a couple of months later you notice that this old algorithm you’ve written way back, might actually match a very current problem, only the data types are slightly different.

What if someone else finds your implementation and likes to use it in a slightly different context. Chances are, that the use will just fail because of circumstances you weren’t aware of when writing the code.

There are several ways to address this. One is to check each and every arguments data type and value range. This will certainly help to make your algorithm work correctly. On the other hand the necessary amount of code to perform all the checks may outnumber the code that actually does the job. There will certainly be issues with readability as well, which we remember was one of the key concepts behind the Python programming language.

A more modern approach to address some of the issues that might occur is exception handling. Most modern high level programming languages do implemment this concept, so does Python.

The idea is that you would optimistically try to do something except an error happens, and when that error happens can cleanly and reliably handle the situation.

Think of something very simple, like eventually dividing by zero. Trying to calculate the result for 1/0 will result in an error. A program running into this issue will fail and exit, which can be very annoying if this happened because of some parameter optimization in a very long running simulation.

In Python we can handle this situation like so

try:
    1 / 0
except ZeroDivisionError as e:
    print(f"An error occured when performing the division: {e}.")
An error occured when performing the division: division by zero.

This is a clean approach to this, as the problem is locally dealt with and the surrounding program can continue.

Python offers a variety of Errors which can be handled. Every possible situation can be cleanly handled with this approach. The advice here is to use this concept as locally as possible. You could of cause wrap your whole application in a large try ... except block. This will end in your application exiting in a controlled way in this case. However the desired result will possibly not available when acting so.

Wrapping a single line where things can go wrong or a few logically related lines of code, and offering a clean way to proceed will be the better, more consistent solution.

Let’s create a simple example to illustrate that. We define a functuin returning 1 over x for a list of numbers.

def one_over_x(xs):
    results = []
    for x in xs:
        results.append(1 / x)
    return results
one_over_x([1, 2, 3, 4])
[1.0, 0.5, 0.3333333333333333, 0.25]

This will work nicely as long as the functio is used “intentionally”. If things become more generic though …

one_over_x(range(-10, 10))
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In[4], line 1
----> 1 one_over_x(range(-10, 10))

Cell In[2], line 4, in one_over_x(xs)
      2 results = []
      3 for x in xs:
----> 4     results.append(1 / x)
      5 return results

ZeroDivisionError: division by zero

We’ll get that ZeroDivisionError. Wrapping the whole thing will prevent the error

try:
    one_over_x(range(-10, 10))
except ZeroDivisionError as e:
    print(e)
division by zero

However there will also be no result set for those numbers not triggering errors.

So handling the Error locally will obviously be the better choice.

def one_over_x(xs):
    results = []
    for x in xs:
        try:
            results.append(1 / x)
        except ZeroDivisionError as e:
            print(f"Warning: {e}.")
    return results
one_over_x(range(-10, 10))
Warning: division by zero.
[-0.1,
 -0.1111111111111111,
 -0.125,
 -0.14285714285714285,
 -0.16666666666666666,
 -0.2,
 -0.25,
 -0.3333333333333333,
 -0.5,
 -1.0,
 1.0,
 0.5,
 0.3333333333333333,
 0.25,
 0.2,
 0.16666666666666666,
 0.14285714285714285,
 0.125,
 0.1111111111111111]

This implementation will inform us that some error occured when doing the calculations, but a - not necessarily complete - result set will be available.

There is also a way to handle more than one exception at once. Think of some preprocessing had accidently merged number and string representations of numbers for the input for our function. We will run into trouble again when trying to execute the function with the new input.

one_over_x([-4, -3, -2, -1, 0, "1", "2", "3", "4"])
Warning: division by zero.
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[8], line 1
----> 1 one_over_x([-4, -3, -2, -1, 0, "1", "2", "3", "4"])

Cell In[6], line 5, in one_over_x(xs)
      3 for x in xs:
      4     try:
----> 5         results.append(1 / x)
      6     except ZeroDivisionError as e:
      7         print(f"Warning: {e}.")

TypeError: unsupported operand type(s) for /: 'int' and 'str'

A slight modification will do the job though.

def one_over_x(xs):
    results = []
    
    for x in xs:
        try:
            results.append(1 / x)
        except ZeroDivisionError as e:
            print(f"Warning: {e}.")
        except TypeError as e:
            print(f"Error: {e}.")
            
    return results
one_over_x([-4, -3, -2, -1, 0, "1", "2", "3", "4"])
Warning: division by zero.
Error: unsupported operand type(s) for /: 'int' and 'str'.
Error: unsupported operand type(s) for /: 'int' and 'str'.
Error: unsupported operand type(s) for /: 'int' and 'str'.
Error: unsupported operand type(s) for /: 'int' and 'str'.
[-0.25, -0.3333333333333333, -0.5, -1.0]

The two errors to handle can also be combined by placing them in a tuple. In this case the error handling block will be the same of course.

def one_over_x(xs):
    results = []
    
    for x in xs:
        try:
            results.append(1 / x)
        except (ZeroDivisionError, TypeError) as e:
            print(f"Warning: {e}.")
            
    return results
one_over_x([-4, -3, -2, -1, 0, "1", "2", "3", "4"])
Warning: division by zero.
Warning: unsupported operand type(s) for /: 'int' and 'str'.
Warning: unsupported operand type(s) for /: 'int' and 'str'.
Warning: unsupported operand type(s) for /: 'int' and 'str'.
Warning: unsupported operand type(s) for /: 'int' and 'str'.
[-0.25, -0.3333333333333333, -0.5, -1.0]

A final word regarding the use of try ... except blocks. It is considered best practise to only use them to prevent unpredictable behaviour of a program. Whenever there are ways to programatically handle a situation, this should be preferred. Think of an example where you might be able to use a simple if ... elif ... else block as well. There are ways to mis-use try ... except blocks for them, ending up in a brute force approach.

In the end, as always, try ... except blocks should be used for preventing program failures and to increase the readability of your coude.