Reading 25: Graphical User Interfaces
Software in 6.031
Objectives
In this reading we talk about listeners, callback functions that are called when an event happens. We discuss this idea in the context of graphical user interfaces, where listeners are used to respond to incoming input events.
Listeners and callbacks are an example of a bigger idea, first-class functions, or treating functions just like data: passing them as parameters, returning them as results, and storing them in variables and data structures. We’ll see more uses for first-class functions over the next few classes.
Input handling in a graphical user interface
Input is handled differently in graphical user interfaces (GUIs) than in typical command-line or text-input user interfaces. A typical text UI has an input loop that displays a prompt, reads a command typed by the user, parses it, and decides how to direct it to different modules of the program.
If a GUI email client were written that way, it might look like this (in pseudocode):
while (true) {
read mouse click
if (clicked on Check Mail button) doRefreshInbox();
else if (clicked on Compose button) doStartNewEmailMessage();
else if (clicked on an email in the inbox) doOpenEmail(...);
...
}
But in a GUI, we don’t directly write this kind of code, because it’s not modular. GUIs are put together from a variety of library components – buttons, scrollbars, textboxes, menus – that need to be self-contained and handle their own input. For example, here is how you create a button (in various languages):
Java:
JButton playButton = new JButton("Play");
HTML:
<button id='playButton'>Play</button>
TypeScript (accompanying the HTML):
const playButton = document.getElementById('playButton');
In order to make your program do something when the button is clicked, you attach a listener to it:
Java:
playButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
playSound();
}
});
TypeScript:
playButton.addEventListener('click', function(event:MouseEvent):void {
playSound();
});
GUI event handling is an instance of the Listener pattern, also known as Publish-Subscribe. In the Listener pattern:
- An event source generates (or publishes) a stream of discrete events, which correspond to state transitions in the source.
- One or more listeners register interest (subscribe) to the stream of events, providing a function to be called when a new event occurs.
An event often includes additional information which might be bundled into an event object (like the ActionEvent
here) or passed as parameters to the listener function.
When an event occurs, the event source distributes it to all subscribed listeners, by calling their listener callbacks.
So the control flow through a graphical user interface proceeds like this:
- A top-level event loop reads input from mouse and keyboard. In Java and most graphical user interface toolkits, this loop is actually hidden from you. It’s buried inside the toolkit, often running on a separate thread created by the toolkit, and listeners appear to be called magically.
- Each listener does its thing (which might involve e.g. modifying objects in the view tree), and then returns immediately to the event loop.
The last part – listeners return to the event loop as fast as possible – is very important, because it preserves the responsiveness of the user interface.
The Listener pattern isn’t just used for button presses. Every GUI object generates events, often as a result of some combination of low-level input events. For example, in Java:
And in HTML:
reading exercises
The code used above to create an ActionListener
for a button:
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
...
}
});
… should remind you of the way we have created the Runnable
of a thread, also using an anonymous class:
new Thread(new Runnable() {
public void run() {
...
}
}).start();
How is a listener similar to a runnable, and how are they different?
(missing explanation)
(missing explanation)
(missing explanation)
The actionPerformed
listener in this section is an example of a callback, where the client of a module provides a piece of code for that module to call:
playButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
playSound();
}
});
What is the client in the example?
(missing explanation)
What is the piece of code in the example?
(missing explanation)
What is that module in the example?
(missing explanation)
Compare and contrast the code for registering an Express route handler:
app.get('/song', function songHandler(request: Request, response: Response):void {
...
});
with the earlier code for registering a listener for a button:
playButton.addEventListener('click', function clickHandler(event:MouseEvent):void {
playSound();
});
(missing explanation)
(missing explanation)
(missing explanation)
First-class functions
Using callbacks requires a programming language in which functions are first-class, which means they can be treated like any other value in the language: passed as parameters, returned as return values, and stored in variables and data structures.
Programming languages are full of things that are not first-class.
For example, access control is not first-class – you can’t pass public
or private
as a parameter into a function, or store it in a data structure.
The notion of public
and private
is an aspect of your program that Java offers no way to refer to or manipulate at runtime.
Similarly, a while
loop or if
statement is not first-class.
You can’t refer to that piece of code all by itself, or manipulate it at runtime.
In old programming languages, only data was first-class: built-in types (like numbers) and user-defined types. But in modern programming languages, like Python and JavaScript, both data and functions are first-class. First-class functions are a very powerful programming idea. The first practical programming language that used them was Lisp, invented by John McCarthy at MIT. But the idea of programming with functions as first-class values actually predates computers, tracing back to Alonzo Church’s lambda calculus. The lambda calculus used the Greek letter λ to define new functions; this term stuck, and you’ll see it as a keyword not only in Lisp and its descendants, but also in Python.
Guido van Rossum, the creator of Python, wrote a blog post about the design principle that led not only to first-class functions in Python, but first-class methods as well: First-class Everything.
In Java, the only first-class values are primitive values (ints, booleans, characters, etc.) and object references. But objects can carry functions with them, in the form of methods. So it turns out that the way to implement a first-class function, in an object-oriented programming language like Java that doesn’t support first-class functions directly, is to use an object with a method representing the function.
We’ve actually seen this before in the context of Java threads: the Runnable
object that you pass to a Thread
constructor is a first-class function, void run()
.
This design pattern is called a functional object, an object whose purpose is to represent a function. The spec for a functional object in Java is given by an interface, called a Single Abstract Method (SAM) interface because it contains just one method.
Lambda expressions in Java
Java’s lambda expression syntax provides a succinct way to create instances of functional objects. For example, instead of writing:
new Thread(new Runnable() {
public void run() {
System.out.println("Hello!");
}
}).start();
we can use a lambda expression:
new Thread(() -> {
System.out.println("Hello!");
}).start();
There’s no magic here: Java still doesn’t have first-class functions. So you can only use a lambda when the Java compiler can verify two things:
- It must be able to determine the type of the functional object the lambda will create.
In this example, the compiler sees that the
Thread
constructor takes aRunnable
, so it will infer that the type must beRunnable
. - This inferred type must be a functional interface: an interface with only one (abstract) method.
In this example,
Runnable
indeed only has a single method —void run()
— so the compiler knows the code in the body of the lambda belongs in the body of arun
method of a newRunnable
object.
Lambda expressions in TypeScript
TypeScript/JavaScript, by contrast, does have first-class functions. A function is a first-class type, alongside objects and primitives.
We have already been using function expressions, starting as far back as Testing, since the mocha framework uses them:
describe("Math.max", function() {
it("covers a < b", function() {
assert.strictEqual(Math.max(1, 2), 2);
});
});
Like Java, TypeScript has a more compact arrow syntax that avoids the need for a function
keyword.
A key difference is that the arrow in TypeScript looks like =>
:
describe("Math.max", () => {
it("covers a < b", () => {
assert.strictEqual(Math.max(1, 2), 2);
});
});
If the body of the function consists of only a single expression (i.e. like a Python lambda
expression), then even the curly braces can be omitted:
it("covers a < b", () => assert.strictEqual(Math.max(1, 2), 2) );
Arrow functions can also be async
, just like function
expressions can:
it("should play the right song", async () => {
const response = await fetch('http://localhost:8000/song/0');
...
});
But it turns out that there is a technical difference beween arrow functions and function
expressions that is important when using methods: a function
expression may redefine this
, but an arrow function uses the definition of this
from its surrouding context.
So arrow functions should always be used inside an instance method, rather than function
expressions.
Understanding This, Bind, Call, and Apply in JavaScript has a good explanation of the issues behind the meaning of this
in JavaScript.
reading exercises
public interface Dog {
public String name();
public Breed breed();
public int loudnessOfBark();
}
We have several Dog
objects, and we’d like to keep a collection of them, sorted by how loud they bark.
Java provides an interface SortedSet
for sorted sets of objects. TreeSet
implements SortedSet
, so we’ll use that.
The TreeSet
constructor takes as an argument a Comparator
that tells it how to compare two objects in the set; in this case, two Dog
s.
Comparator
is a functional interface: it has a single unimplemented method: int compare(...)
.
SortedSet<Dog> dogsQuietToLoud = new TreeSet<>(... /* a Comparator<Dog> that sorts by loudness */);
dogsQuietToLoud.add(...);
dogsQuietToLoud.add(...);
dogsQuietToLoud.add(...);
// ...
An instance of Comparator
is an example of:
(missing explanation)
Which of these would create a TreeSet
to sort our dogs from quietest bark to loudest?
Read the documentation for Comparator.compare(...)
to understand what it needs to do.
new TreeSet<>(new Comparator<Dog>() {
public int compare(Dog dog1, Dog dog2) {
return dog2.loudnessOfBark() - dog1.loudnessOfBark();
}
});
(missing explanation)
new TreeSet<>(new Comparator<Dog>() {
public Dog compare(Dog dog1, Dog dog2) {
return dog1.loudnessOfBark() > dog2.loudnessOfBark() ? dog1 : dog2;
}
});
(missing explanation)
new TreeSet<>((dog1, dog2) -> {
return dog1.loudnessOfBark() - dog2.loudnessOfBark();
});
(missing explanation)
public class DogBarkComparator implements Comparator<Dog> {
public int compare(Dog dog1, Dog dog2) {
return dog1.loudnessOfBark() - dog2.loudnessOfBark();
}
}
// ...
new TreeSet<>(new DogBarkComparator());
(missing explanation)
Consider this anonymous class from earlier in the reading:
playButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
playSound();
}
});
Fill in the blanks to transform the anonymous class into a lambda expression:
playButton.addActionListener(▶▶A◀◀ -> ▶▶B◀◀);
(missing explanation)
Implementing an event source
All our examples of callbacks so far have been from the client’s point of view. The client creates a callback function, passes it to the implementer, and when the desired event occurs, the implementer calls it.
What about the implementer’s side? How does an implementer keep track of callback functions, and make the calls? To illustrate this, we’ll implement a simple counter that calls its listeners whenever it increments.
Counter spec
Counter
has several operations:
export class Counter {
/** Make a counter initially set to zero. */
public constructor() { ... }
/** @return the value of this counter. */
public get value():bigint { ... }
/** Increment this counter. */
public increment():void { ... }
/** Modifies this counter by adding a listener.
* @param listener called by this counter when it changes. */
public addEventListener(listener: NumberListener):void { ... }
/** Modifies this counter by removing a listener.
* @param listener will no longer be called by this counter. */
public removeEventListener(listener: NumberListener):void { ... }
}
We also define NumberListener
as an alias for the type of the listener function, which simply takes the number that the counter has reached:
type NumberListener = (numberReached:bigint) => void;
Be careful here: in this context =>
is a function type, not a function expression.
From a client’s point of view, we can make a counter and attach listeners to it:
const counter = new Counter();
counter.addEventListener(function(value:bigint) {
console.log(value);
});
const counter = new Counter();
counter.addEventListener((value:bigint) => console.log(value));
Whenever counter.increment()
is called, Counter
will call this listener, which will print the new number.
Counter implementation
Let’s turn to the implementer’s view.
Counter
needs to track both the number it is counting and the set of listeners who want to be called back.
So we’ll use this straightforward rep:
private _value: bigint = 0n;
private listeners: Array<NumberListener> = [];
// Abstraction function
// AF(_value, listeners) = a counter currently at `_value`
// that sends events to the `listeners` whenever it changes
// Rep invariant
// true
The counting itself is handled by increment()
:
public increment():void {
++this._value;
this.callListeners();
}
The essence of implementing the Listener pattern is the cooperation between the public mutators addEventListener()
and removeEventListener()
, which clients use to register/unregister their callback functions, and the private method callListeners()
that the implementation uses to call those callback functions.
The operations might then be implemented like this:
public addEventListener(listener: NumberListener):void {
this.listeners.push(listener);
}
public removeEventListener(listener: NumberListener):void {
for (let i = 0; i < this.listeners.length; ++i) {
if (this.listeners[i] === listener) {
this.listeners.splice(i, 1);
return;
}
}
}
private callListeners():void {
for (const listener of listeners) {
listener(this._value);
}
}
In this code, addEventListener()
/removeEventListener()
maintain the set of listeners.
Whenever the counter increments, it calls callListeners()
to iterate through the set, calling each listener’s numberReached()
method.
Unfortunately this straightforward implementation has a subtle bug. The following reading exercises uncover the bug and fix it.
reading exercises
Suppose a client only cares to be called back once, and then removes itself to stop further calls:
function oneShotListener(value:bigint) {
console.log(value);
counter.removeEventListener(oneShotListener);
}
counter.addEventListener(oneShotListener);
Assume that addEventListener()
, removeEventListener()
, and callListeners()
are implemented as shown above, and that the counter is being steadily incremented in a background thread.
Which of the following is most likely to happen when this listener is called?
(missing explanation)
Here is the full code for Counter
.
Summary
Callbacks are our first example of first-class functions, where we treat functions just as we treat data: passing them to and returning them from other functions, and storing them to call later.
Callbacks make code more ready for change by allowing clients to provide their own code to run when an event occurs, so that the behavior doesn’t have to be hard-coded into the implementation beforehand.
Writing a single giant input loop to handle every possible event in a large system is neither safe from bugs nor easy to understand: that single piece of code becomes an all-knowing all-controlling behemoth. Callbacks allow each module in the system to be responsible for their own events.
In future readings, we’ll see further benefits to all three of these properties of good software from the idea of first-class functions.