Chapter 4. Expecting the Unexpected

Programs are very fragile. It would be nice if code always returned a valid result, but sometimes a valid result can't be calculated. It's not possible to divide by zero, or to access the eighth item in a five-item list, for example.

In the old days, the only way around this was to rigorously check the inputs for every function to make sure they made sense. Typically functions had special return values to indicate an error condition; for example, they could return a negative number to indicate that a positive value couldn't be calculated. Different numbers might mean different errors occurred. Any code that called this function would have to explicitly check for an error condition and act accordingly. A lot of code didn't bother to do this, and programs simply crashed.

Not so in the object-oriented world! In this chapter we will study exceptions, special error objects that only need to be handled when it makes sense to handle them. In particular, we will cover:

  • How to cause an exception to occur
  • How to recover when an exception has occurred
  • How to handle different exceptions with different code
  • Cleaning up when an exception has occurred
  • Creating new exceptions
  • Using the exception syntax for flow control

Raising exceptions

So what is an exception, really? Technically, an exception is just an object. There are many different exception classes available and we can easily define more of our own. The one thing they all have in common is that they derive from a built-in class called BaseException.

These exception objects become special when they are handled inside the program's flow of control. When an exception occurs, everything that was supposed to happen doesn't happen, unless it was supposed to happen when an exception occurred. Make sense? Don't worry, it will!

So then, how do we cause an exception to occur? The easiest way is to do something stupid! Chances are you've done this already and seen the exception output. For example, any time Python encounters a line in your program that it can't understand, it bails with a SyntaxError, which is a type of exception. Here's a common one:


>>> print "hello world"
	File "<stdin>", line 1
		print "hello world"
						^
SyntaxError: invalid syntax

That print statement was a valid command in Python 2 and previous versions, but in Python 3, because print is a function, we have to enclose the arguments in parenthesis. So if we type the above into a Python 3 interpreter, we get the SyntaxError exception.

A SyntaxError, while common, is actually a special exception, because we can't handle it. It tells us that we typed something wrong and we better figure out what it is. Some other common exceptions, which we can handle, are shown in the following example:


>>> x = 5 / 0
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
ZeroDivisionError: int division or modulo by zero

>>> lst = [1,2,3]
>>> print(lst[3])
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
IndexError: list index out of range
	
>>> lst + 2
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
TypeError: can only concatenate list (not "int") to list

>>> lst.add
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
AttributeError: 'list' object has no attribute 'add'

>>> d = {'a': 'hello'}
>>> d['b']
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
KeyError: 'b'

>>> print(this_is_not_a_var)
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
NameError: name 'this_is_not_a_var' is not defined
>>>

Sometimes these exceptions are indicators of something wrong in our program (in which case we would go to the indicated line number and fix it), but they also occur in legitimate situations. A ZeroDivisionError doesn't always mean we received invalid input, just different input. The user may have entered a zero by mistake, or on purpose, or it may represent a legitimate value such as an empty bank account or the age of a newborn child.

You may have noticed all the above built-in exceptions end in the name Error. In Python, the words "error" and "exception" are used almost interchangeably. Errors are sometimes considered more dire than exceptions, but they are dealt with in exactly the same way. Indeed, all the error classes above have Exception (which extends BaseException) as their superclass.

Raising an exception

Now, then, what do we do if we're writing a program that needs to inform the user or a calling function that the inputs are somehow invalid? It would be nice if we could use the same mechanism that Python uses… and we can! Want to see how? Here's a simple class that adds items to a list only if they are even numbered integers:

	class EvenOnly(list):
		def append(self, integer):
			if not isinstance(integer, int):
				raise TypeError("Only integers can be added")
			if integer % 2:
				raise ValueError("Only even numbers can be added")
			super().append(integer)	

This class extends the list built-in, as we discussed in Chapter 2, and overrides the append method to check two conditions that ensure the item is an even integer. We first check if the input is an instance of the int type, and then use the modulus operator to ensure it is divisible by two. If either of the two conditions is not met, the raise keyword is used to cause an exception to occur. The raise keyword is simply followed by the object being raised as an exception. In the example above, two objects are newly constructed from the built-in classes TypeError and ValueError. The raised object could just as easily be an instance of a new exception class we create ourselves (we'll see how shortly), an exception that was defined elsewhere, or even an exception object that has been previously raised and handled.

If we test this class in the Python interpreter, we can see that it is outputting useful error information when exceptions occur, just as before:


>>> e = EvenOnly()
>>> e.append("a string")
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
	File "even_integers.py", line 7, in add
		raise TypeError("Only integers can be added")
TypeError: Only integers can be added

>>> e.append(3)
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
	File "even_integers.py", line 9, in add
		raise ValueError("Only even numbers can be added")
ValueError: Only even numbers can be added
>>> e.append(2)

Note

Note: While this class is effective for demonstrating exceptions in action, it isn't very good at its job. It is still possible to get other values into the list using index notation or slice notation. This can all be avoided by overriding other appropriate methods, some of which are double-underscore methods.

What happens when an exception occurs?

When an exception is raised, it appears to stop program execution immediately. Any lines that were supposed to happen after the exception are not executed, and, unless the exception is dealt with, the program will exit with an error message. Take a look at this simple function:

	def no_return():
		print("I am about to raise an exception")
		raise Exception("This is always raised")
		print("This line will never execute")
		return "I won't be returned"

If we execute this function, we see that the first print call is executed and then the exception is raised. The second print statement is never executed, and the return statement never executes either:


>>> no_return()
I am about to raise an exception
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
	File "exception_quits.py", line 3, in no_return
		raise Exception("This is always raised")
Exception: This is always raised

Further, if we have a function that calls a second function that raises an exception, nothing will be executed in the first function after the point where the second function was called. Raising an exception stops all execution right up the function call stack until it is either handled, or forces the interpreter to exit. To demonstrate, let's add a second function that calls our first one:

	def call_exceptor():
		print("call_exceptor starts here...")
		no_return()
		print("an exception was raised...")
		print("...so these lines don't run")		

When we call this function, we see that the first print statement executes as well as the first line in the no_return function. But once the exception is raised, nothing else executes:


>>> call_exceptor()
call_exceptor starts here...
I am about to raise an exception
Traceback (most recent call last):
	File "<stdin>", line 1, in <module>
	File "method_calls_excepting.py", line 9, in call_exceptor
no_return()
	File "method_calls_excepting.py", line 3, in no_return
		raise Exception("This is always raised")
Exception: This is always raised

We'll soon see that the interpreter is not actually taking a shortcut and exiting immediately; the exception can be handled inside either method. Indeed, exceptions can be handled at any level after they are initially raised. If we look at the exception's output (called a traceback) from bottom to top, we see both methods listed. Inside no_return, the exception is initially raised. Then just above that, we see that inside call_exceptor, that pesky no_return function was called and the exception "bubbled up" to the calling method. From there it went up one more level to the main interpreter, which finally printed the traceback.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.30.62