Week 11 - Error Handling and Debugging

There is no programming without error handling, as your programs will inevitably encounter errors. In fact, we've been dealing with errors since we started to learn Python back in week 3. Every error is different, some errors will only happen in certain conditions, some errors will take hours to solve, if not days. Here's a very fun error to read :-)

Nevertheless, it's a programmer's duty to handle errors responsibly. First, let's go over the different types of errors.

Syntax errors

Every language has rules to make correct sentences, and these rules are called syntax. Naturally, every programming language has their own rules of making valid "sentences", and so it also has syntax to follow.

Here are some examples (from the slides) that break the rules, and therefore result in "syntax error":

Note that the invalid syntax isn't print(a) above, but it's caused by the interpreter trying to find the missing parenthesis in the above statement.

These are generally the easiest errors to solve.

Type errors

Type errors are caused by using the wrong type, or using mismatched types together. Here are some examples of type errors:

Run-time Errors

Run-time errors are those errors that are only encountered when running your code. These have correct syntax, and use the correct types. They require a bit of closer inspection to understand where the error is coming from.

The count() example below finds the count of numbers between 1 and 999 (inclusive) that are divisible by a given number n.

As we see, count() generally works for the numbers that we provide. However, nothing stops us from using 0 as the input number:

This is a run-time error that we see when we try to divide a number by 0, which is what happens when we call count(0).

With list-like structures, we frequently see indexing errors such as the one below:

In OOP, when we try to access a function or class variable that does not exist, we encounter an attribute error:

There are many different types of run-time errors, one can inspect the table in the chapter about run-time errors for more examples.

Logical errors

Finally, logical errors are when our code does not behave as we expect. These errors are sometimes the hardest ones to solve -- they might not even terminate our program!

Here's a naive error to make when we forget operator precedence. Suppose we want to code

$$ y = \frac{x}{x+1} $$

like this:

We expected the result to be $y = \frac{4}{5} = 0.8$, so why is this the case?

This is because / takes higher precedence than +, so this will be read by Python as y = (x / x) + 1, which is always 2 (unless x = 0, in which case we'll have a problem).

To fix this, we should add parenthesis ourselves to make the grouping more explicit:

Here's another example from function scoping.

Let's try to change the value of x, while we're in a():

This would be a logical error since we're trying to change the value of x. We can solve this by specifying the global keyword:

Sometimes we forget to return values when we're in a function, like this example:

f(5, 3) has type NoneType because we don't return a value when x >= y inside f().

Logical errors are not limited to these examples, and can be caused by anything and everything. That's what makes them harder to solve.

Dealing with Errors

Now that we know how what types of errors exist, we can try our best to eliminate them using a checklist-type of approach.

First of all, just being mindful of good coding practices goes a long way. It's easier to solve a problem if you don't introduce it in the first place, right? When we're writing code, we should think of the general outline of the code, as well as some cases where our code may fail (such as with specific inputs).

In particular, if we're dealing with numbers for example, we may try to detect if the input that we received can be handled by our code. This is called input sanitization, and it's a good way to eliminate errors:

Taking the square root of a negative number will result in a run-time error:

So we can eliminate this possibility by checking that the a given input is positive:

In the example above, in the case of invalid input, we return None to show that the result is not valid. Sometimes we can return special error codes (such as -1 here, as no real number could have a square root of -1) or other tokens.

In general, to signal that we've encountered an unexpected situation, one can use exceptions. Exceptions are special objects that signify errors and are handled differently than other things that we've seen until now. In fact, the run-time errors (ValueError, etc.) that we've seen are actually exceptions.

For example, let's use exceptions above:

First of all, we don't return exceptions, we raise (or "throw") them. This way, you can see your call stack (the traceback above) and specifically which line your code had an error.

You can also provide a message to be shown along with the error, like we did above.


$\tiny{\text{Trivia: You technically can return exceptions (Python won't complain), but it's the same as returning any normal object, and you lose out on the special exception handling utilities.}}$

Since you don't return exceptions, you don't have to be in any function to raise an exception:

Finally, an exception will "bubble" up in your program until it is handled, or the program terminates. Here's an example of an exception interrupting a function while it is executing:

Finally, you can even create your own exception type, by inheriting from the BaseException class.

Here's the example from class, where we wrote a "safer" fibonacci number function that only accepts positive integers as its input.

Now, just as we raise an exceptions, we must also deal with thrown exceptions.

We deal with exceptions using try...except blocks, like below:

There's no traceback, because we caught the exception with the try-except block. We can check for multiple types of exceptions by chaining except statements:

Of course, it goes without saying that if you are not the one responsible to handle an exception, you should not catch it so that it is dealt with accordingly (by some other code).

Writing test cases

It's a good practice to test your implementation of a specific feature of your code so that it works well with expected situations. That way, when the code changes (maybe months later), you can be sure that it works on some known cases.

Curious students can check out the unittest module for writing test cases in a more modular manner.

Debugging

Debugging is the act of removing bugs (errors) from programs (why "bug"? see here for historical context).

There are multiple ways of debugging, and the easiest method is to add print statements to show the values of variables between execution.

We solved the problem of Arimu and Hats from the workbook in class, and in the coding process we printed our results along the way:

For more complicated debugging, it's better to use something called a debugger, which is a special program that can runs while your executing your code and gives you finer control over code execution. IDEs such as PyCharm, VSCode, etc. all have nice debuging screens where you can see values of variables, function scopes, and more; but the simplest application of using a debugger is by using the pdb module.

You can use pdb by importing it. Afterwards, to stop your code in a specific location, you can add a breakpoint by using pdb.set_trace().

You can use the following commands to inspect your code:

There are more commands (continue, etc.) to use, it's very fun to play with pdb.