CMSC 14100 — Lecture 25

Raising exceptions

At some point, when you work on a program that is complicated enough, you may want to raise exceptions of your own. To do this, we use a raise statement. Simply raise the desired exception with an appropriate message as an argument.


    >>> raise ValueError("You gave a wrong value")
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    ValueError: You gave a wrong value
    

We can, of course, catch any such errors in the same way as usual.


    >>> try:
    ...     raise ValueError("You gave a wrong value")
    ... except ValueError as e:
    ...     print(f"Caught our ValueError: {e}")
    ... 
    Caught our ValueError: You gave a wrong value
    

There are many built-in exceptions that one can use in Python. However, you may want to define your own. Since exceptions are classes, you simply need to define a class. Suppose we want to define MyError. We do so in the following way:


    class MyError(Exception):
        pass
    

Here, we have some new syntax. When defining our class, we pass in the name of another class, Exception. What does this mean? This is an example of inheritance. Here, MyError (and all exceptions) are subclasses that are derived from Exception. This means that MyError will have, by default, the same attributes and methods as Exception and will be treated like an Exception.

Inheritance and subclassing is a topic that you'll get into in detail in CMSC 14200.

But suppose we do not include this:


    class MyError:
        pass
    

    >>> raise MyError
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: exceptions must derive from BaseException
    

Now, if we use our original definition, we will get this:


    >>> raise MyError
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    MyError
    

Since these are classes, you can add attributes and methods for them, including dunder methods. Since exceptions are derived from the Exception class, they take a message in their constructor.


    >>> raise MyError("Something went wrong here")
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    MyError: Something went wrong here
    

Of course, we would never raise an exception just to immediately catch it—after all, if we knew we were in a situation where an error would occur, it would be better to avoid the situation in the first place. Typically, exceptions get raised because we can't avoid them or deal with them and someone else needs to deal with it.

What happens to exceptions that we don't catch? Where and when do they get dealt with? Recall that when an exception occurs you typically get a stack trace along with it. Because the exception interrupts the flow of control, this means it necessarily exits the current stack frame and proceeds to the next one, and so on, until it reaches the bottom of the stack, which is where the "main" process was launched.

Because the exception travels down the stack, there are opportunities to catch the exception further down the stack. Let's consider the following example.


    def divide(n, d):
        if d == 0:
            return n / d
        if d == 1:
            return n / c
        s = []
        return s[d]

    def f(x):
        try:
            divide(32, x)
        except ZeroDivisionError:
            print('Division by zero in f')
        finally:
            print('Finally f')
        print('Finished f')

    def g(x):
        try:
            f(x)
        except ZeroDivisionError:
            print('Division by zero in g')
        except NameError:
            print('Name error in g')
        finally:
            print('Finally g')
        print('Finished g')
    

In each of the following, we call g with different inputs. This will cause different exceptions and we will see how different cases are handled.

First, we call g(0).


    >>> g(0)
    Division by zero in f
    Finally f
    Finished f
    Finally g
    Finished g
    

Here, we run g(0). This will call f(0), which calls divide(32, 0). This causes a ZeroDivisionError in divide. So control leaves divide and goes back to f. Since we call divide in a try block, we are able to catch the ZeroDivisionError.

By handling the ZeroDivisionError, we then execute the code in the finally block and continue with the rest of the function. When control returns to g, we continue by executing the finally block and proceeding with the rest of the function.

It is important to point out that we do not enter the except ZeroDivisionError block in g because this exception has already been taken care of in f—it does not travel any further down the stack and is gone.

Next, we consider g(1).


    >>> g(1)
    Finally f
    Name error in g
    Finally g
    Finished g
    

Here, our call descends into divide again, but this time we enter the x == 1 block and run into a NameError, since c is not defined. Note that an undefined name is a runtime error and we don't encounter it until the problematic code is actually executed.

As a side note, you may wonder why we need to run the line in order to know that the name hasn't been defined. Shouldn't it be possible to syntactically verify that a name hasn't been defined yet, without running the code? Though there are programs that can do quite well with this, it turns out that this is impossible to do perfectly—no such algorithm exists.

Now, when we hit our NameError, control leaves divide and goes back to f. But f does not have an except block to handle the situation. So it must propagate the exception. But before control leaves f, the finally block is run—finally will always run, whether or not an exception is raised or caught.

Now, notice that because the exception is propagated, we do not continue with the rest of the function as we did before. The exception must be handled before we can proceed with the regular operation of our program.

Once control leaves f and returns to g, we see that there is an except NameError block that handles the exception. After this is handled, the finally block runs and the rest of the function proceeds as normal.

Finally, we consider g(2).


    >>> g(2)
    Finally f
    Finally g
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "<stdin>", line 3, in g
      File "<stdin>", line 3, in f
      File "<stdin>", line 7, in divide
    IndexError: list index out of range
    

Here, we make it past the conditionals to the list s. But since s is empty, we refer to an index of s that doesn't exist, producing an IndexError. This causes control to leave divide.

When control returns to f, we find that we have no code to catch the IndexError, so we need to propagate the exception. As before, we execute finally and relinquish control. But we find ourselves in the same situation in g: there is no code to handle the IndexError. So finally is executed but the rest of the function does not execute, since we still need to handle the exception.

Since there is no code to catch the exception, our program halts and we are given the stack trace.