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.
Callbacks
A callback is a function that a client provides to a module for the module to call. This is in contrast to normal control flow, in which the client is doing all the calling: calling down into functions that the module provides. With a callback, the client is providing a piece of code for the implementer to call.
We already saw one kind of callback in Map/Filter/Reduce: the function passed to map
or filter
or reduce
, which is called repeatedly for each element of the sequence.
This is a synchronous callback, because it is called only during the execution of the map
/filter
/reduce
, and then not used thereafter.
The use of the callback is synchronized within the call and return of the map
/filter
/reduce
.
This reading considers asynchronous callbacks, which are saved by the module for a future call, after the function that received the callback has already returned.
Here’s one analogy for thinking about the difference between synchronous and asynchronous. Normal (synchronous) function calling is like picking up the phone and calling a service, like calling your bank to find out the balance of your account. You give the information that the bank operator needs to look up your account, they read back the account balance to you over the phone, and you hang up. You are the client, and the bank is the module that you’re calling into.
Sometimes the bank is slow to give an answer. You’re put on hold, and you wait until they figure out the answer for you. This is like a synchronous function call that blocks until it is ready to return.
But sometimes the task may take so long that the bank doesn’t want to put you on hold. Then the bank will ask you for a callback phone number, and they will promise to call you back with the answer, at some unpredictable point in the future. This is analogous to providing an asynchronous callback function.
Sometimes a callback function will be called only once (or at most once), as a response to a one-time request, as in this account-balance example. But a callback function may also be called repeatedly, whenever a certain event happens. An analogy for this kind of callback might be account fraud protection, where the bank calls you at your registered phone number whenever a suspicious transaction occurs on your account. We will see both kinds of asynchronous callbacks in this reading.
Timer callbacks
A simple example that uses a callback is the built-in JavaScript function setTimeout
, which takes a callback function and a number of milliseconds, and calls the function after the specified time has elapsed:
setTimeout(() => console.log("beep!"),
3000);
Here the callback function is the function object created by the lambda expression () => console.log("beep!")
.
Running this code at a TypeScript prompt would produce:
> setTimeout(() => console.log("beep!"), 3000);
>
3 seconds later
beep!
Notice that the callback is asynchronous. The call to setTimeout()
itself doesn’t wait for 3000 milliseconds (3 seconds) before returning.
Instead, it starts an internal timer, stores a reference to the callback function, and then returns right away, and the next TypeScript prompt >
is printed to wait for the user to type another expression.
Once the internal timer expires, three seconds later, then the callback function is finally called, printing beep!
.
A side note: setTimeout
is unusual for expecting its callback function as the first argument.
It’s an unfortunate design choice, since when a call to setTimeout
is written on one line with a lambda expression (as in the example just above), it can be hard to tell where the lambda expression ends and the remaining arguments to setTimeout
start.
Usually a callback function appears at the end of a parameter list, and the remaining examples in this reading all use that convention instead.
To see an example of defining a function that takes a callback, let’s create a countdown timer:
/**
* Start a timer that ticks once per second until it expires.
* @param ticks duration of the timer in seconds, must be an integer >= 0
* @param callback callback function, initially called synchronously,
* then called again after each tick of the timer,
* each time passing the number of seconds left until the timer expires.
* ticksLeft must be an integer in [0, ticks].
*/
function countdown(ticks: number, callback: (ticksLeft: number) => void): void
Look closely at the type of the callback
parameter: this is a function type.
In this case, countdown()
is expecting a callback function that takes a single number
argument and returns void
.
Here’s how it might be called at a TypeScript prompt:
> countdown(3, (n:number) => console.log(n) );
3
>
one second later
2
one second later
1
one second later
0
Notice where 3
is printed in the transcript above: before the next prompt >
was displayed, because the spec indicates that the first callback should be called synchronously, before countdown()
returns. The remaining calls to the callback happen asynchronously, as the timer counts down to completion.
The exercises below explore the behavior of the setTimeout()
and countdown()
callbacks, and show how to implement countdown()
using setTimeout()
.
reading exercises
setTimeout(() => console.log("A"), 4000);
setTimeout(() => console.log("B"), 1000);
console.log("C");
In what order are A, B, and C printed by this code?
(missing explanation)
What is the total duration of the program, from the start of the code to the final line printed, rounded to the nearest second?
(missing explanation)
Recall the function signature for countdown
, with particular attention to the callback
parameter:
function countdown(ticks: number, callback: (ticksLeft: number) => void): void
and suppose the client has previously defined this function:
function myCallback(ticksLeft:number) { console.log(ticksLeft); }
Which of the following are true statements about the callback that the client can pass to countdown
?
(missing explanation)
Which of the following are legal ways for the implementation of countdown
to call the callback? Assume that n
is a local variable with the number of ticks remaining on the timer.
(missing explanation)
Let’s consider how countdown()
might be implemented using setTimeout()
. Here is a recursive implementation:
/**
* Start a timer that ticks once per second until it expires.
* @param ticks duration of the timer in seconds, must be an integer >= 0
* @param callback callback function, initially called synchronously,
* then called again after each tick of the timer,
* each time passing the number of seconds left until the timer expires.
* ticksLeft must be an integer in [0, ticks].
*/
function countdown(ticks: number, callback: (ticksLeft: number) => void): void {
const millisecondsPerTick = 1000;
if ( ticks > 0 ) {
setTimeout(_____________, millisecondsPerTick);
}
callback(ticks);
}
Which of the following expressions could be put in the blank for the recursive case, so that the implementation works for ticks > 0
?
(missing explanation)
What does this code do when called with countdown(0, (ticksLeft) => console.log(ticksLeft))
?
(missing explanation)
Now let’s consider an imperative implementation of countdown()
, which is currently buggy:
/**
* Start a timer that ticks once per second until it expires.
* @param ticks duration of the timer in seconds, must be an integer >= 0
* @param callback callback function, initially called synchronously,
* then called again after each tick of the timer,
* each time passing the number of seconds left until the timer expires.
* ticksLeft must be an integer in [0, ticks].
*/
function countdown(ticks: number, callback: (ticksLeft: number) => void): void {
const millisecondsPerTick = 1000;
function doTick() {
callback(ticks);
if ( ticks > 0 ) {
setTimeout(doTick, millisecondsPerTick);
}
}
doTick();
}
What does this code do when called with countdown(0, (ticksLeft) => console.log(ticksLeft))
?
(missing explanation)
What does this code do when called with countdown(1, (ticksLeft) => console.log(ticksLeft))
?
(missing explanation)
The bug in the iterative implementation is that it never decrements ticks
. Let’s try fixing it:
/**
* Start a timer that ticks once per second until it expires.
* @param ticks duration of the timer in seconds, must be an integer >= 0
* @param callback callback function, initially called synchronously,
* then called again after each tick of the timer,
* each time passing the number of seconds left until the timer expires.
* ticksLeft must be an integer in [0, ticks].
*/
function countdown(ticks: number, callback: (ticksLeft: number) => void): void {
const millisecondsPerTick = 1000;
//A
function doTick() {
//B
callback(ticks);
//C
if ( ticks > 0 ) {
//D
setTimeout(doTick, millisecondsPerTick);
//E
}
}
//F
doTick();
//G
}
Suppose you insert the statement --ticks;
at just one of the locations marked A-G. Which of the possible insert locations would fix the bug, so that countdown()
obeys its spec?
(missing explanation)
Event loop
These timer callbacks allow us to uncover a fundamental aspect of the TypeScript/JavaScript programming model. At the core of the JavaScript runtime system is an event queue, a first-in-first-out queue that stores events that have arrived from various sources. The queue is serviced by an event loop that repeatedly checks this queue for newly-arrived events, and calls appropriate callback functions.
One source of events are the timers created by setTimeout
. The expiration of a timer causes a “timer done” event to be placed at the end of the queue, along with its callback function. When the event loop eventually retrieves this event from the top of the queue, it calls the callback function, finally discharging the obligation of setTimeout
’s contract.
Later in this reading, we will see another source of events: a graphical user interface. For JavaScript running in an HTML web page, the user’s keypresses, mouse movements and presses, or finger-touches are placed at the end of the event queue, and eventually handled by callback functions.
And still another source of events are input and output to the filesystem and network, which we will see in a future reading.
All our TypeScript/JavaScript code is actually running inside the event loop — even the start of the program is initiated from the event loop. In order for the program to work correctly, all our code must also return to the event loop in a timely way, so that the event loop can move on to the next event, and continue processing subsequent timer expirations or GUI/file/network events.
If any of our code blocks — holding onto the flow of control and not returning to the caller for a significant period of time — then the event loop stops processing events. Timer callbacks don’t fire, the user interface stops responding, and network connections stop delivering data. If you have ever seen your mouse cursor turn into a spinning beach ball or an hourglass, that’s a sign of a program whose event loop has become blocked, so that it is no longer processing events. It’s bad.
Here’s a simple function that would block the JavaScript event loop:
/** Wait for `milliseconds` to pass before returning. */
function busyWait(milliseconds: number): void {
const now = new Date().getTime(); // new Date().getTime() is always the current clock time in milliseconds
const deadline = now + milliseconds;
while (new Date().getTime() < deadline) {
// do nothing, just wait until the clock reaches the deadline time
}
}
A call to busyWait(10000)
spins and spins around its while
loop, watching the clock, until 10 seconds have passed.
During that time, events will pile up in the event queue, but the event loop will not be able to process them, because busyWait()
has not returned.
Once busyWait()
returns its caller (which then returns to its caller, and so forth all the way back up to the event loop, which is the original caller of everything in JavaScript), then the event loop can finally peel off the next waiting event and call its callback function, and life can return to normal.
But all those timer callbacks that piled up will fire too late!
Spinning in a loop waiting for something to change externally is called busy-waiting, and it is a very bad code smell for an event-loop runtime system like JavaScript.
Code like busyWait()
needs to be rewritten. It should use a callback triggered by the change, as setTimeout()
does.
reading exercises
The busyWait()
function is definitely a bad design for TypeScript/JavaScript. But even well-meaning functions can inadvertently block the event loop:
/** @returns a random prime number with `n` decimal digits. */
function randomPrime( n: number ): bigint;
Suppose randomPrime()
is implemented by a guess-and-test strategy, generating n-digit numbers at random and testing them for primality until it finds a prime one.
So randomPrime(1000)
may take an unpredictable amount of time to run.
Which possible behaviors might you see from the example code below? Choose all that apply.
setTimeout(() => console.log("A"), 1000);
console.log(randomPrime(1000));
setTimeout(() => console.log("B"), 2000);
(missing explanation)
One difference between synchronous and asynchronous callbacks is how they behave with exception handling.
Let’s first consider a synchronous callback that always throws an exception, used by map
:
function alwaysThrows(n:number) {
throw new Error("boom!");
}
[1, 2, 3].map(alwaysThrows);
Which of the following places to put a try-catch
statement will catch the exception thrown by alwaysThrows
? Assume the choices are independent, i.e. the try-catch
is put in only one place in the code.
(missing explanation)
Now let’s consider an asynchronous callback, used by setTimeout
:
function alwaysThrows() {
throw new Error("boom!");
}
setTimeout(alwaysThrows, 1000);
Which of the following places to put a try-catch
statement will catch the exception thrown by alwaysThrows
? Assume the choices are independent, i.e. the try-catch
is put in only one place in the code.
(missing explanation)
HTML and the Document Object Model
Asynchronous callbacks are heavily used in graphical user interfaces. To understand how, we need to start 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. 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, or change their appearance. 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
(x,y) = position of mouse click
if (mouse was clicked on the Draw button) drawRandomShape();
else if (mouse was clicked on the Clear button) clearCanvas();
else if (mouse was clicked somewhere on the canvas) drawShapeAtPosition(x, y);
...
}
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. In the Listener pattern:
- An event source generates a stream of discrete events.
- One or more other modules subscribe (or listen) 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:
- The top-level event loop reads input from mouse and keyboard. In HTML/JavaScript, 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)
Event sources
The listener pattern is a general programming idea, and there are two ways it appears in TypeScript/JavaScript programming:
in web pages, as we have just seen, objects that can act as event sources implement the
addEventListener()
method, which is part of theEventTarget
interface.in Node, event sources implement the
on()
method, which is part ofEventEmitter
. AndaddListener()
is a synonym foron()
.
The addEventListener()
and on()
/addListener()
methods have essentially the same parameters: a string naming the type of the event, and a callback function that will be called when the event occurs.
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="sizeTextbox" 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:
sizeTextbox.addEventListener('input', (event:ChangeEvent) => {
// make the slider match the updated textbox
sizeSlider.value = sizeTextbox.value;
});
sizeSlider.addEventListener('input', (event:ChangeEvent) => {
// make the textbox match the updated slider
sizeTextbox.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 input
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: (numberReached:bigint) => void):void { ... }
/** Modifies this counter by removing a listener.
* @param listener will no longer be called by this counter. */
public removeEventListener(listener: (numberReached:bigint) => void):void { ... }
}
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<(numberReached:bigint) => void> = [];
// 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: (numberReached:bigint) => void):void {
this.listeners.push(listener);
}
public removeEventListener(listener: (numberReached:bigint) => void):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 this.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
Let’s look closely at the type of the listeners
field in the rep for Counter
:
private listeners: Array<(numberReached:bigint) => void> = [];
Suppose Counter
wants to call the i
th listener on this array, passing it the value n
as the counter value reached. Which of the following expressions does that correctly?
(missing explanation)
What is the type of this call expression?
(missing explanation)
Suppose a client of Counter
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 has multiple listeners attached to it, not just this one.
The counter increments, and Counter
now calls all its listeners, synchronously.
Which of the following is likely to happen when oneShotListener
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.