ICS H32 Fall 2024
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:
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 (protected) 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:
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.
def
statement really does. It really does two things: Creates a function and then stores it in (more or less) a variable. When we do that at the "top-level" of a module (i.e., not inside of any functions or classes), then we've stored the function globally within that module. When we do that within a class, we've stored the function as a method in that class. When we do it within another function, we've stored the function as a local variable in that function.make_duplicate_printer
does when you call it is create a new function and store it in a local variable called print_duplicates
. The function it creates takes a string argument called s
and returns no value.print_duplicates
function uses the variable count
, which is actually one of the arguments of make_duplicate_printer
, the function that print_duplicates
is defined in. That's not as crazy as it looks; that's perfectly legal. The reason is that print_duplicates
doesn't exist until make_duplicate_printer
is called, because it's make_duplicate_printer
that builds it. At the time make_duplicate_printer
is building print_duplicates
, there will be a value of count
— this only happens during a call to make_duplicate_printer
. So, any variable available to make_duplicate_printer
is also available to print_duplicates
.make_duplicate_printer
is called — it will then be returned to the caller of make_duplicate_printer
.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:
Counter.reset
returns a function object, just like square
did. If you wanted to call it, you'd need to pass the arguments it requires. The Counter.reset
method requires two arguments: a Counter object (self
) and an integer (new_count
).c.reset
returns a bound method object instead. A bound method is one in which the self
parameter has already been bound to the object preceding the dot (in this case, c
). If we want to call a bound method, we pass it the missing arguments (i.e., the arguments other than self
). In this case, there's just one, new_count
.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:
self
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.