Programming 1, 2021-2
Week 11: Notes

Some of today's topics are covered in these sections of Think Python:

Here are some more notes.

raising and catching exceptions

You have undoubtedly noticed that Python's built-in operators and library functions sometimes report errors. For example, the index() method returns the index of the first occurrence of a value in a sequence, but produces a ValueError if the value is not present:

>>> [3, 4, 5, 6].index(5)
2
>>> [3, 4, 5, 6].index(7)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: 7 is not in list

Similarly, the open() function produces a FileNotFoundError if a file does not exist:

>>> open('non_existent_file')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'non_existent_file'

These errors are actually exceptions, which are a mechanism supported by Python and many other languages. Any code that wants to report an error can raise (= throw) an exception. In the examples above, index() raised a ValueError exception, and open() raised a FileNotFoundError exception.

By default, an exception will terminate the program. However, Python's tryexcept statement can catch an exception and handle it in some other way. For example, suppose that we want to open a file and read its contents if the file exists, but still continue executing if it does not. We might write

try:
    f = open('poem')
    text = list(f)   # read all file lines into a list
except FileNotFoundError:
    print('warning: poem not found')
    text = []
print(len(text))

If open() runs without error, the code will read the file and the code in the 'except' block will not run. If open() raises a FileNotFoundError, then the code in the 'except' block will run, and then execution will continue normally after the 'try' statement since the exception has been handled. If open() raises some other kind of exception, then the except: block will not run, and the program will terminate (unless some enclosing code catches the exception that has been raised).

Note that an exception is actually an object, i.e. an instance of the built-in class Exception or one of its subclasses. ValueError and FileNotFoundError are classes that inherit from Exception. Each type of exception may have attributes that describe the error that occurred. For example, a FileNotFoundError has an attribute 'filename' containing the file that was not found. In a try … except statement, you can give a name to the exception that was caught and can examine its attributes:

name = input('Enter filename: ')

try:
    f = open(name)
    line = f.readline()
except FileNotFoundError as e:
    print(f'file not found: {e.filename}')

You may define your own classes of exceptions. For example, suppose that we're writing a stack class and we'd like to report an error if the caller attempts to pop a value from an empty stack. We may write

class EmptyStackException(Exception):
    pass

(As we've seen before, the 'pass' statement does nothing, and we can use it when writing a class with no methods.)

Now, in our stack class, we might write

    def pop(self):
        if self.is_empty():
            raise EmptyStackException()
        

The raise statement raises an exception. If the caller does not catch the exception, the program will be terminated.

In this example, an EmptyStackException has no attributes. If we like, we could give the EmptyStackException class an __init__() initalizer that stores attributes in an instance, and then they would be available to a caller who catches this exception in a try … except statement.

Note that an exception raised by a function f need not be caught by the immediate caller of f. Consider this example:

def a():
    try:
        b()
    except FileNotFoundError:
        print('file not found')

def b():
    c()

def c():
    open('poem')
    print('successful open')

In this code, the call to open() in c() might raise a FileNotFoundError. There is no try … except statement in c(), or in its caller b(). However, a() contains a try … except statement that can catch a FileNotFoundError. If a FileNotFoundError is raised, Python will unwind the call stack, aborting the execution of c() and then b() until it arrives at the try … except statement in a(), which will catch the exception.

We see that a raise statement is a form of non-local exit that causes execution to jump to some outer point. In fact we've already seen two other statements in Python that can also jump out from the current execution point. Namely, 'break' immediately exist the current loop iteration, and 'return' immediately exits the current function call. 'raise' is more powerful in that it can immediately exit a series of nested function calls extending from a try … catch statement down to the function that raises the exception.

Here's one more point about exceptions. In a try … except statement, you can choose to specify no exception type at all, in which case the statement will catch any exception at all:

try:
    foo()
except:
    print('some error occurred')

However I don't generally recommend using this form of try … except. A try … except statement is easier to read when it indicates the type of exception that it anticipates. Furthermore, if some sort of error occurs other than the one that you expected to handle, then this form of try … except will catch it, which may lead to behavior that is surprising and difficult to debug.

nested functions

Python allows us to write nested functions, i.e. functions that are defined inside other functions or methods.

As an example, suppose that we'd like to write a function replace_with_max() that takes a square matrix m and returns a matrix n in which each value in m is replaced with the maximum of its neighbors in all 4 directions. For example, if m is

2 4
5 9

then replace_with_max(m) will return

5 9
9 5

As a first attempt, we might write

def replace_with_max(m):
    size = len(m)
    
    # Make a matrix of dimensions (size x size) filled with zeroes
    n = [ size * [ 0 ] for _ in range(size) ]
    
    for r in range(size):
        for c in range(size):
            n[r][c] = max(m[r  1][c], m[r + 1][c],
                          m[r][c  1], m[r][c + 1])
                          
    return n

However, we have a problem: if a square (r, c) is at the edge of the matrix, then an array reference such as m[r][c + 1] might go out of bounds.

To solve this problem, let's write a nested helper function get(i, j) that returns an array element if the position (i, j) is inside the matrix, otherwise (- math.inf), i.e. -∞. Here is the improved function:

def replace_with_max(m):
    def get(i, j):
        if 0 <= i < size and 0 <= j < size:
            return m[i][j]
        else:
            return -math.inf
    
    size = len(m)
    
    # Make a matrix of dimensions (size x size) filled with zeroes
    n = [ size * [ 0 ] for _ in range(size) ]
    
    for r in range(size):
        for c in range(size):
            n[r][c] = max(get(r - 1, c), get(r + 1, c),
                          get(r, c - 1), get(r, c + 1))
                          
    return n

Notice that the nested function get() can refer to the parameter 'm'. It can also refer to the local variable 'size' that is defined in its containing function replace_with_max(). This is quite convenient. If we declared the helper function get() outside the function replace_with_max(), it would have to take m and size as extra parameters, and we would have to pass these values on each call to get(), which would be a bother.

updating outer variables in nested functions

In the previous example, we saw that the nested function get() can read the values of the variables 'm' and 'size' in the containing function. What if get() wants to update the value of such a variable? For example, suppose that we want to count the number of calls to get() made inside a single call to replace_with_max(). We could attempt to write

def replace_with_max(m):
    g = 0     # number of calls to get()

    def get(i, j):
        g += 1
        if 0 <= i < size and 0 <= j < size:
            return m[i][j]
        else:
            return -math.inf
    

However, that won't work because as we have seen before, any variable that is updated inside a function is local by default in Python. And so in the code above, Python will think that 'g' is a local variable inside get(), and will report an error when we first attempt to increment it.

One possible solution would be to make 'g' global, and use a declaration 'global g' inside get(). However, that's a bit ugly since 'g' doesn't really need to be global. A better way is to declare g as nonlocal:

def replace_with_max(m):
    g = 0     # number of calls to get()

    def get(i, j):
        nonlocal g
        g += 1
        if 0 <= i < size and 0 <= j < size:
            return m[i][j]
        else:
            return -math.inf
    

Now the code will work. The nonlocal statement is somewhat like the global statement in that it declares that a variable is not local. The difference is that global declares that a variable is to found in the global (i.e. top-level) scope, whereas nonlocal declares that a variable is a local variable in an enclosing function.

functions as return values

A function can return a function. As an example, let's write a function add_n(n) that takes an integer n and returns a function that adds n to its argument. As one possible approach, we can define a nested function and then return it:

def add_n(n):
    def adder(x):
        return x + n
        
    return adder

Let's try it:

>>> f = add_n(10)
>>> f(5)
15

Alternatively, we can write add_n using a lambda:

def add_n(n):
    return lambda x: x + n

In these functions, we say that the parameter n has been captured by the returned function. Although it is a parameter to add_n(), it continues to exist even after add_n() has returned: the returned function can access the value of n when it is called.

transforming a function

We can write a function that takes a function f as an argument and returns a transformed function based on f.

For example, let's write a function twice() that takes a function f and returns a function g such that g(x) = f(f(x)) for any x. We can define a nested function and return it:

def twice(f):
    def g(x):
        return f(f(x))

    return g

Let's try it:

>>> f = twice(lambda x: x + 10)
>>> f(5)
25

We can even pass the result of twice() back to the same function, yielding a function that applies the original function four times:

>>> f = twice(twice(lambda x: x + 10))
>>> f(5)
45

As before, we can alternatively define twice() using a lambda:

def twice(f):
    return lambda x: f(f(x))

model-view architecture

We have now learned enough about Tkinter to begin to write some interesting graphical programs.

In many such programs, it's helpful to separate the code into a model and a view. The model is a class (or set of classes) that represent the state of the world and the rules for updating state. For example, if we are writing a chess game, there could be a model class Game that represents a game of chess. It might have a constructor Game() that constructs a new game. It could also have methods such as

Model classes know nothing about the user interface.

The view is a class (or set of classes) that is responsible for rendering the model, i.e. drawing the state of the world.

In this form of architecture, there is often an additional component called a controller that is responsible for processing input. When input arrives, the controller receives it and calls model methods to update the state of the world. Then someone (either the controller or the model, depending on the program) notifies the view that the state has changed and it's time to redraw it. The view makes calls model methods to learn about the state that needs to be displayed.

In my experience, this sort of architecture leads to cleaner code than in programs where the model and view code are tangled together. I'd encourage you to use it when writing many graphical programs (or even when writing some programs with a textual user interface).