ICS 32 Winter 2022
Notes and Examples: A Tkinter Application


Background

Having seen some of the basics of how the tkinter library works, and noting the similarities between what we saw and how PyGame works, we are ready to embark on building a tkinter application. Setting up a tkinter application requires three basic tasks:

  1. Creating the widgets we'll need in our window.
  2. Specifying how those widgets will participate in a layout (i.e., Where should they be displayed, and how does that change as the size of the window changes?).
  3. Decide what events we need to handle and set up our event handlers to be notified when they occur.

As we did when we wrote PyGame-based applications, we should tend to want to arrange all of this in a class, an object of which represents one instance of our application. The __init__() method can set everything up, a run() method can be called to allow tkinter to take charge (by calling mainloop() on our Tk object), and handlers for various events will be additional (private) methods in the same class.

Also, as we did with PyGame, we should want to keep the "model" and the "view" separate. Even though we're using a different library for it, we're still writing a program with the same basic structural characteristics. There is code that is meant to control how the program looks and how the user interacts with it; that's the view. There is code that is meant to embody how the program works internally — the details of the problem we're solving — and that's the model.

Model/view separation leads to all kinds of benefits, not the least of which is it also opens an avenue to using the test-driven development techniques we learned previously. While tkinter is not designed in a way that would make it easy for us to write automated tests for things like the placement of buttons in a window, the things we're most interested in testing — where the majority of the complexity in a real application with a graphical user interface tend to lie — are the "guts", anyway. If the model is kept completely separate, with no interaction with tkinter and designed in a way that is automatically testable, we'll have no problems approaching it using a test-driven development style. The view, on the other hand, can be built and tested the "old fashioned" way, which is to say that we'll build it and interact with it manually to make sure it's working properly.

First, though, there are a couple of additional bits of Python we're going to need on our journey. Those are summarized below.


The implications of functions in Python being objects

We've seen previously that functions in Python are objects. This allows us to store them in variables, pass them as parameters, and so on. For example, if we have this function that squares a number:

def square(n: 'number') -> 'number':
    return n * n

along with this function that takes a one-argument function as a parameter, applies it to every element of a list, and returns a list of the results:

def apply_to_all(f: 'one-argument function', elements: list) -> list:
    results = []

    for element in elements:
        results.append(f(element))

    return result

then we could square all of the elements of a list by simply calling apply_to_all() and passing the square() function to it as a parameter.

>>> L = [1, 2, 3, 4, 5]
>>> apply_to_all(square, L)
[1, 4, 9, 16, 25]

There's a critically important piece of syntax here. When we passed the square() function as a parameter, we didn't follow it with parentheses; we simply named it. This is because we didn't want to call the function and pass its result to apply_to_all(); instead, we wanted to pass the function itself, with the understanding that apply_to_all() was going to call it for us (once for each element of the list). The expression square(3) calls the square function and passes the argument 3 to it and evaluates to the function's result; the expression square evaluates to the function itself, an object that we can call later by following it with parentheses and passing it an argument.

Functions that build and return functions

The fact that functions are objects — just like strings, lists, integers, and so on — implies that we can pass them as arguments to other functions, as we did in the previous section. This isn't considered special in Python, and it's not an exception to any rule. It's simply a natural consequence of two separate facts:

  1. You can pass objects as arguments to functions.
  2. Functions are objects.

If functions are objects, then it stands to reason that we should be able to use them in other ways that objects are used. Not only can you pass objects as arguments, but you can also return them from functions. (In fact, in Python, all functions return an object. Even the ones that don't reach a return statement will return an object called None.)

So, if you can return objects from functions, can you return functions from functions? The answer is an unqualified "Yes!" Functions are objects. You can return objects from functions. Therefore, you can return functions from functions.

But why would you ever want to return a function from a function? What functions do is automate a task. You give them arguments and they give you a result. If you wanted to automate the task of building functions — because you wanted to build lots of similar functions that were different in only a small way — you could do so by writing a function that takes whatever arguments are necessary to differentiate the functions from each other, build one of them, and return it to you.

As an example, imagine you wanted to write a collection of functions that each took a string as an argument and printed that string a certain number of times. One of the functions prints it once; one of the functions prints it twice; and so on. (You could, of course, just write a function that takes an argument specifying the number of times you want to print it, but remember the overall goal here: We write to write event-handling functions in tkinter, in which case we don't have control over what arguments these functions are allowed to take. The "command" function associated with a button must accept no arguments, because tkinter will never pass it any.) Here's one way to do that.

def make_duplicate_printer(count: int) -> 'function(str) -> None':
    def print_duplicates(s: str) -> None:
        for i in range(count):
            print(s)

    return print_duplicates

What's going on here? Let's break it down a bit.

How do we use make_duplicate_printer?

>>> make_duplicate_printer(3)
<function make_duplicate_printer.<locals>.print_duplicates at 0x00000168A5171E18>
>>> x = make_duplicate_printer(3)
>>> type(x)
<class 'function'>
>>> x('Boo')
Boo
Boo
Boo
>>> make_duplicate_printer(4)('Boo')
Boo
Boo
Boo
Boo

We can store the result of make_duplicate_printer in a variable. Notice that if we ask its type, we see that it's a function. And, since it's a function, we can call it by following its name with parentheses and passing it an argument. That's even true in the last example, where we do all of this in a single expression: ask make_duplicate_printer to build us a function, then call that resulting function.

Methods are functions, but they can become bound methods

As we've seen, classes contain methods, which are called on objects of that class. For example, suppose we have this class, similar to one from a previous code example.

class Counter:
    def __init__(self):
        self._count = 0

    def count(self) -> int:
        self._count += 1
        return self._count

    def reset(self, new_count: int) -> None:
        self._count = new_count

Given that class, we've seen before that we can call its methods in one of two ways:

>>> c = Counter()
>>> c.reset(3)
>>> Counter.reset(c, 3)

The call c.reset(3) is the typical way we write a call to a method. This says "I want to call the reset() method on the object c." However, Python internally translates the call to the other notation we saw, Counter.reset(c, 3), which we don't normally write, but which makes clearer the relationship between the arguments to the call and the parameters of the method: c is assigned into self because it's the first argument that was passed, while 3 is assigned into new_count because it's the second.

But what if we leave out the parentheses, like we did with the square() function previously? Then things get more interesting.

>>> Counter.reset
<function Counter.reset at 0x02E23030>
>>> c.reset
<bound method Counter.reset of <__main__.Counter object at 0x02E21670>>

The funny-looking hexadecimal numbers specify where the objects are stored in memory, so they'll probably be different for you than they were for me, and they aren't important here. But the types are more interesting:

To see how this works, consider the following example:

>>> x = Counter.reset
>>> x(c, 3)
>>> y = c.reset
>>> y(3)

The first two lines set a variable x to be the Counter.reset function and then call it. That function requires two arguments, so if we want to call it, we have to pass both: self and new_count. The second two lines set a variable y to be the bound method c.reset instead. That bound method requires only one argument, since self has already been bound to c, to we call it by passing it the one argument, which is then bound to new_count.


Writing object-oriented GUIs

A tkinter-based GUI is written using event-based programming techniques, which means that we ask tkinter to handle the basic control flow and watch for inputs, then notify us only when events occur in which we've registered an interest. Some kinds of event handler functions, like the ones that handle the command behavior on the Button widget, take no parameters; most take a single parameter, an event object that describes the event. But if we use functions (as opposed to methods) for our event handlers, the only information they'll have available to them is the information passed to them; they might know something about the event that's occurring now, but won't have access to any other information about what's happened before. That makes even a simple application like our Scribble application very difficult to write.

However, there is a saving grace in Python: classes and object-oriented programming. We've seen before that classes are a way to bring together data and the operations that know how to manipulate that data. The objects you create from classes don't just store things; they know how to do things with the data they store. In the case of GUIs built using tkinter, they beautifully solve the problem of ensuring that necessary information is available to event handlers. If event handlers are methods that are called on an object, those methods will have access not only to the event object that's passed to them (if any), but also any other information that's stored within the object (via self). So long as the necessary information is stored in the object's attributes, it will be available in event handler methods; if one event handler changes one of these attributes, its updated value will be available to subsequently-called event handlers.

Using bound methods for event handlers

The only trick, then, is how to use a method as an event handler. If tkinter will call an event handler like it's a function and pass it, say, a single argument that is an event object, how can a method that also requires a self argument be a parameter?

The answer lies in the bound methods we presented earlier. If an event handler is a bound method, the self argument is already bound; tkinter won't need to pass it. And when tkinter calls the event handler and passes it an event object, what it's actually calling is a method that takes a self and an event object, meaning that it has access to the two things it needs:

The use of bound methods as event handlers is demonstrated in the code example below.


The code

Below is a link to a partially-complete version of our Calculator application from lecture. What's left is entirely to be done in the model and the unit tests; the GUI is done. The good thing about that is everything we have left to do is based around concepts we've already learned about; the "hard part" is finished.