Reading 20: Callbacks and Graphical User Interfaces
Software in 6.031
Objectives
In this reading we talk about callbacks, in which an implementer calls a function provided by the client. We discuss this idea in the context of graphical user interfaces (GUIs), where a kind of callback function called a listener is used to respond to incoming input events from the user.
We’ll see more instances of callbacks in future classes, when we talk about web servers and asynchronous computation.
HTML and the Document Object Model
We’ll begin by looking at how user interfaces are structured, using HTML web pages as an example.
HTML consists of elements, which are delimited by a start tag and end tag. For example, here is how a button is described in HTML:
<button id='drawButton'>Draw</button>
… which the web browser renders like this:
The start tag often contains attributes, like id
here.
The id
attribute is a unique identifier for the element.
Other attributes may specify other properties of the element.
For example, here is a 256-by-256-pixel drawing surface:
<canvas id="drawingCanvas" width="256" height="256"></canvas>
(The <canvas>
element has the Canvas
drawing interface that we are using in ps3.)
HTML elements are nested in other HTML elements, to form a tree. Below is a snippet of HTML that groups two buttons together in one row, and the canvas in another row, using the <div>
grouping element.
The way it might render in a web page is shown on the right.
<div>
<button id="drawButton">Draw</button>
<button id="clearButton">Clear</button>
</div>
<div>
<canvas id="canvas" width="256" height="256">
</canvas>
</div>
The tree of elements is called the Document Object Model (DOM). Each element of the tree occupies a certain portion of the screen, generally a rectangular area called its bounding box. The tree is not just an arbitrary hierarchy, but is in fact a spatial one: child elements are typically nested inside their parent’s bounding box.
Virtually every GUI system has some kind of tree of visual elements (not just HTML). The element tree is a powerful structuring idea, which is loaded with responsibilities in a typical GUI:
Output. Attributes and children of the element affect what the element displays on the screen. For example, the label on the button is the text found between its start tag and end tag.
Input. Elements can have input handlers that respond to mouse and keyboard input from the user, and to other events. More on this in a moment.
Layout. The DOM controls how elements are laid out on the screen, i.e. how their bounding boxes are assigned. An automatic layout algorithm automatically calculates the position and size of each element, controlled by style rules written in Cascading Style Sheets (CSS). CSS layout is outside the scope of what we’ll be talking about in 6.031; 6.170 or IAP Web.Lab are good places to learn it.
Connecting to TypeScript
To make an interactive user interface, HTML can also include JavaScript code (or TypeScript which has been compiled into JavaScript).
The code can obtain references to elements in the tree using document.getElementById()
:
const drawButton: HTMLButtonElement = document.getElementById('drawButton');
DOM elements are mutable, so the code can reassign instance variables or call mutators to add and remove elements from the tree. For example, this code changes the button’s label:
drawButton.text = 'Draw Randomly';
Input handling
Input is handled differently in 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 were written that way, it might look like this (in pseudocode):
while (true) {
read mouse click
if (clicked on Draw button) drawRandomShape();
else if (clicked on Clear button) clearCanvas();
else if (clicked somewhere on the canvas) drawShapeAtPosition(getMouseX(), getMouseY());
...
}
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. Deep inside the graphical user interface library is an input loop that is reading from the mouse and keyboard, and passing those input events to the appropriate components of the GUI.
drawButton.addEventListener('click', (event:MouseEvent) => {
drawRandomShape();
});
The second argument to this method is a function expression using arrow syntax, which we saw in a previous class.
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.
- One or more listeners register interest (subscribe) to the stream of events, providing a function to be called when a new event occurs.
In this example:
An event often includes additional information which might be bundled into an event object (like the MouseEvent
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 HTML, like most other GUI frameworks, this loop is actually hidden from you. It’s buried inside the GUI library, and listeners appear to be called magically.
- Each listener does its thing (which might involve e.g. mutating objects in the user interface), 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 design 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 sent by input devices, like a mouse, keyboard, or touchscreen. For example, in HTML:
- the mouse sends low-level events
mousedown
(the mouse button was pressed down) andmouseup
(when the button was released) - the keyboard sends similar low-level events
keydown
andkeyup
when keyboard keys are pressed and released - a button (
<button>
) sends aclick
event after it receives bothmousedown
andmouseup
. - a dropdown menu (
<select>
) sends aninput
event when the user selects a different element from the dropdown menu (whether by mouse or by keyboard) - a textbox (
<input type=text>
) sends aninput
event when the text inside it changes, as a result of keyboard or mouse events.
reading exercises
The 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:
drawButton.addEventListener('click', (event:MouseEvent) => {
drawRandomShape();
});
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)
Pitfalls in listeners
There are a few common pitfalls in the listener design pattern, particularly when used in GUIs, that it’s important to be aware of.
Listeners should run quickly and return quickly. If a listener function responds to an event by doing a long, slow computation, then it will stall the user interface. Since the listener was called by the GUI event loop, the event loop will not be able to continue processing input events until the listener returns.
For example, suppose the draw button’s listener drew a large number of random shapes:
drawButton.addEventListener('click', (event:MouseEvent) => {
for (let i = 0; i < 1000*1000; i++) {
drawRandomShape();
}
});
Drawing a million rectangles may involve a long, long pause. During that time, the web page appears frozen. Mouse clicks don’t seem to work, scrolling doesn’t work, keypresses don’t seem to be handled. On many platforms, the operating system changes the mouse cursor to an hourglass or colorful spinning beachball to show that the user interface is stuck.
Once the million rectangles are finally all drawn, and the listener function returns, then the event loop resumes processing. All the input events you tried to do while it was stuck were stored in a queue, so it will eventually catch up.
The lesson here is: in graphical user interaces, listener functions must run quickly and return quickly, typically within a few milliseconds. If a listener needs to do a long-running computation, it should use concurrency, which we will talk about in upcoming classes.
Be careful about cycles. Sometimes it is necessary to have two GUI elements that mutually depend on each other, like the textbox and slider in the code below:
<div>
Size:
<input type="number" id="sizeBox" value="10" min="1" max="50" maxlength="2">
<input type="range" id="sizeSlider" value="10" min="1" max="50">
</div>
In this design, the user should be able to change the size either by typing a number in the box, or by dragging the slider. Either way of doing it should update the other element as well, so that they remain consistent. So it’s tempting to attach listeners like this, to propagate the value from the textbox to the slider, and vice versa::
sizeBox.addEventListener('input', (event:ChangeEvent) => {
// make the slider match the updated textbox
sizeSlider.value = sizeBox.value;
});
sizeSlider.addEventListener('input', (event:ChangeEvent) => {
// make the textbox match the updated slider
sizeBox.value = sizeSlider.value;
});
This creates a cycle of listeners. If the textbox changes, its listener responds by mutating the slider, which might in turn trigger the listener for the slider, which mutates the textbox, which might then trigger the textbox listener again. There is a risk of an infinite loop.
In this case, the cycle is broken by specification: the change
event is specified to be sent only when the user changes the value of the textbox or slider, using mouse or keyboard input. Mutations of the value caused by code do not trigger the event. So there is no risk of infinite loop in this particular case.
Implementing an event source
So far we have seen listeners only 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 listeners, and call them when an event occurs? To illustrate this, we’ll implement a simple counter that calls its listeners whenever it increments.
Counter spec
Counter
has several operations:
class Counter {
/** Make a counter initially set to zero. */
public constructor() { ... }
/** @returns 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 a type 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((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 {
// search backwards through the array,
// so we can remove all matches to `listeners`
// without having to adjust the index
for (let i = this.listeners.length-1; i >= 0; --i) {
if (this.listeners[i] === listener) {
this.listeners.splice(i, 1);
}
}
}
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.
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 another 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.