there’s a certain Pet Peeve i happen to have with event-driven programming as a whole. a lot of javascript libraries especially (looking at you, Literally All Of NodeJS’s API) happen to use a paradigm which i dub Event-Driven Programming, and i will explain what i mean by that, but first, i need to take a detour into something that seems fairly unrelated:
this essay’s title happens to be a reference to dijkstra’s famous letter Go To Statement Considered Harmful[0] (this was before the keyword “goto” became fully lexicalized in programmer circles), and that isn’t just because it’s a kind of funny way to express disapproval of a concept in general, but also because i’m arguing against something fairly similar.
the argument against GOTO relies on a
simple idea: how much information does one need to,
unambiguously, determine where in the flow of execution
a program has reached. and more critically: how does
that information relate to the program itself and its
structure. (to define “location within the flow of
execution” more rigorously, imagine that
one’s rerunning the program from the start; what
information does one need to successfully set a
breakpoint in the program?)
when there are precisely zero control flow structures, this is fairly easy. one just points to the statement that is being executed. this is one integer, and is as directly related to the source code of the program as much as it is related to the execution of the program.
when we introduce IF ELSE THEN
(conditionals), nothing changes. the execution of the
program is, still, entirely dependent on which
statement is being executed at the moment.
: ; (procedures/subroutines/functions)
throw a wrench into this. now, one has to also keep
track of the return stack. this means
one’s statement numbers evolve into
stacks of statement numbers, but, that’s
still fairly reasonable, and it maps onto the source
code of the program a-ok.
BEGIN WHILE REPEAT (loops) also make this
a bit more messy, but one can just count the number of
iterations done within a loop. this requires adding two
new elements to the stack (iteration count &
statement number in the loop body), too, because loops
can be nested, however, this is not too bad.
LABEL GOTO throws a wrench into this
system, but this time it actually causes severe
damage. because any GOTO can go to any
LABEL, often without even any additional
structure, all hopes of having a structured index for
program execution are crushed. the only solution that
remains is to count the number of statements executed
from the start of the program, which is as correct as
it is entirely meaningless.
this is why GOTO statements, in general,
suck. they make program control flow hard to
reason about, which is why we have banished them to the
realm of hardcore assembly fanatics.
i jest, somewhat. LABEL GOTO still has
it’s place in relatively high level langauges,
such as C[1].
in C, LABEL GOTO is a fairly intuitive
& nonintrusive way to handle error
cases[2]:
when error cases get incredibly tangled in several
layers of loops and conditionals, this can serve to
cut through them.
parse_binary(s)
char s[];
{
register n = 0;
register char *p = s;
while (*p) {
if (*p == '0') {
n <<= 1;
} else if (*p == '1') {
n <<= 1;
n |= 1;
} else {
goto fail; /* goto stmt */
}
}
return n;
fail: /* used for the failure case (invalid binary) */
/* error reporting foo... */
return -1;
}
as a matter of fact, structured code is
turing-complete. unstructured code (here meaning
“code with LABEL GOTOs in
it”) happens to also be turing-complete. this
means that any structured program can be translated
into an unstructured program (trivially), and any
unstructured program can be translated into a
structured program (not as trivial). the argument is
as follows:
take each statement, and put it in a control flow
graph. for instance, one might have:
firstly, we can rewrite this to use labels on each and
every statement:
a = 4;
b = 3;
foo:
++a;
if (b) goto bar;
c = 3;
++b;
goto baz;
bar:
c = 0;
baz:
a += b;
a += c;
every statement can now end with a
s0: a = 4;
s1: b = 3;
s2: ++a;
s3: if (b) goto s7;
s4: c = 3;
s5: ++b;
s6: goto s8;
s7: c = 0;
s8: a += b;
s9: a += c;
GOTO
of some sort:
next, we can emulate this via a
s0: a = 4; goto s1;
s1: b = 3; goto s2;
s2: ++a; goto s3;
s3: if (b) goto s7; else goto s4;
s4: c = 3; goto s5;
s5: ++b; goto s6;
s6: goto s8;
s7: c = 0; goto s8;
s8: a += b; goto s9;
s9: a += c; goto s10;
BEGIN CASE ( ... ) END-CASE AGAIN
structure, with a variable to tell us which statement
we’re executing:
now, there’s no denying that this is a rather
ugly translation, and that it’s possible to
translate this code significantly more nicely,
however, this is a bullet-proof, systematic way to
translate an arbitrary unstructured program
into structured form.
register state = 0;
for (;;) {
if (state == 0) { a = 4; state = 1; }
else if (state == 1) { b = 3; state = 2; }
else if (state == 2) { ++a; state = 3; }
else if (state == 3) { if (b) state = 7;
else state = 4; }
else if (state == 4) { c = 3; state = 5; }
else if (state == 5) { ++b; state = 6; }
else if (state == 6) { state = 8; }
else if (state == 7) { c = 0; state = 8; }
else if (state == 8) { a += b; state = 9; }
else if (state == 9) { a += c; state = 10; }
}
we shall make note of the hallmark of
“unstructured code google-translated into a
structured programming language”: a set of
IFs that execute multiple times (usually,
but not necessarily, by means of a loop) and that
check equality with some sort of variable such that
the bodies of each of those IFs change
the variable as their last operation.
we are now prepared to venture back into event-driven programming.
what i mean by “event-driven programming” is the particular methodology of programs that manifest whenever one has asynchronous (or similar) operations.
― vdupras, DuskOSConcurrency has been with us for so long that, as software developers, we’ve internalized its primary constraint: global variables and states should be avoided.
To some of us, avoiding these became a second nature, so much that we don't even see the complexity associated with these habits anymore.
…
Naively, we underestimate that weight being lifted off because we forget that compounding effects are exponential. With practice, I can tell you: it’s much more than you might think.
this is an evergreen statement.
the examples of event-driven programming causing insanity are, in fact, too numerous for me to universally list them off here, so i’ll be fixating on only a few examples:
requestAnimationFrame
requestAnimationFrame in javascript is a
function that takes in a callback function, and calls
it back once the browser is done drawing the current
webpage to the screen. it’s a one-shot function,
meaning that the callback is only called back once,
and then discarded. seems like a reasonable design
decision, does it not?
let’s just pull up the
MDN web docs for
requestAnimationFrame and oh god what the
hell is that.
let zero;
requestAnimationFrame(firstFrame);
function firstFrame(timestamp) {
zero = timestamp;
animate(timestamp);
}
function animate(timestamp) {
const value = (timestamp - zero) / duration;
if (value < 1) {
element.style.opacity = value;
requestAnimationFrame((t) => animate(t));
} else element.style.opacity = 1;
}
okay, i may be a functional programming advocate, but
this is too many functions for me. firstly,
(t) => animate(t) is a
laughable lambda. but what is slightly more
terrifying is what that lambda is doing. it’s a
function that is recursing via a callback. even more
annoyingly, it’s a callback-aided tail
recursion. javascript is an imperative language. the
fact that this function is forced to tail
recurse is a sign that something has gone wrong.
if you’re eagle-eyed, you may have already
spotted where this argument is going. there’s an
if statement that checks some sort of
value (appropriately yet completely uselessly named
value), that if statement
runs in a loop (tail-recursion), and it conceptually
mutates that value (the way it sidesteps having to
actually mutate it is by keeping track of
zero).this is, in fact,
unstructured code. even better, one of the branches of
the if statement actually hops
out of the tail-recursion. you know what we
call goto statement backward chained with
an if that skips the backwards
goto? a while loop.
this entire code snippet, composed of two functions
and an uninitialized global variable, is a
deconstructed, functional, while loop.
we can, in fact, rewrite this to just be a normal
while loop, and the code becomes a great
deal simpler:
let start = nextFrame();
let value;
while ((now = nextFrame()) < start + duration)
element.style.opacity = (now - start) / duration;
element.style.opacity = 1;
not only that, but it’s now actually structured.
the only trouble is that it’s synchronous. we
can fix that by meticulously adding the asynchronicity
back. the issue with callbacks is that they lead to
callback hell, and the solution to callback hell tends
to either be to factorize everything heavily
(a good solution, but not always possible: one
can’t slice a function in half arbitrarily and
expect to be able to give both halves good names!),
or... to use the feature in javascript specifically
built to combat this. oh, hey, await!
function nextFrame() {
return new Promise(function (resolve, reject) {
try {
requestAnimationFrame(resolve);
} catch (error) {
reject(error);
}
});
}
let start = await nextFrame();
let value;
while ((now = await nextFrame()) < start + duration)
element.style.opacity = (now - start) / duration;
element.style.opacity = 1;
another issue with requestAnimationFrame
is it returns the current timestamp instead of a
deltatime of some sort. this isn’t
terrible (and makes sense if one has several
different functions all hooking into
requestAnimationFrame independently), but
complicates code fairly often, so you might want to
consider making that change if you plan to use this.
setTimeoutthis has the same shtick:
function keepTryingX() {
if (!tryX()) {
/* failure case */
setTimeout(keepTryingX, 10000);
}
}
this is another while loop. the reason
why these examples are while loops
specifically is because those are what makes it
blatantly obvious. most of the time, they’re
just endlessly chained together in callback hell, but
a while loop forces one to create a
wholly new function.
function sleep(time) {
return new Promise(function (resolve, reject) {
try {
setTimeout(resolve, time);
} catch (error) {
reject(error);
}
};
}
while (!tryX())
await sleep(1000);
WebSocketnow we dip into the example that drove me to making this article in the first place. websockets. now, i have very, very mixed opinions on websockets. this is one of the negative ones.
say i want to implement a fairly simple protocol. i
will describe it in the most natural way that it comes
to my mind: the client will connect to the server, and
send the server SYN. the server will then
respond with ACK, followed by
SYN. the client will respond to this with
ACK. the server will then periodically
send PING to the client, and the client
shall respond with PONG. that’s it.
that’s the whole protocol. notice how i
described it imperatively: this happens, then this
happens, then this happens. let’s see how one
would implement this over websockets...
let socket = new WebSocket("ws://localhost:8080");
let socketState;
socket.addEventListener("open", function (event) {
socketState = 0;
socket.send("SYN");
});
socket.addEventListener("message", function (event) {
if (socketState == 0) {
if (event.data == "ACK") {
socketState = 1;
}
} else if (socketState == 1) {
if (event.data == "SYN") {
socket.send("ACK");
socketState = 2;
}
} else if (socketState == 2) {
if (event.data == "PING") {
socket.send("PONG");
}
}
});
what do we see here? ...a set of IFs that
execute multiple times (by means of an event
listener), and that check equality with some sort of
variable (socketState) such that the
bodies of each of those IFs change the
variable as their last operation.
this is a deconstructed structured program.
that’s the trouble with event-driven
programming. it’s not just similar to
an unstructured program, it is an
unstructured program. it is, definitionally, what
happens when one tries to shoehorn an unstructured
program into a structured programming language, and
all the benefits of switching to structured
programming apply here too! notice how i described the
protocol. i described it incredibly imperatively, i
never said “the client then waits for an
incoming message. if it’s in the [foo]
state and recieves a SYN, it sends an
ACK and goes into the [bar]
state.”. i said “it waits for
SYN, then sends ACK”
we can fix this through a short series of basic
improvements: firstly, i need to note that the exact
step that causes code to be “unstructured in
nature” is not the state variable itself, but
the repeated IF checks. even more
specifically, it’s the repetition
that’s the problem. so, let’s eliminate
the repetition by forcing each event handler to
execute only once:
function listen(socket, eventType, handler) {
function internal(event) {
socket.removeEventListener(eventType, internal);
handler(event);
}
socket.addEventListener(eventType, internal);
}
listen(socket, "open", function (event) {
socket.send("SYN");
listen(socket, "message", function (event) {
if (event.data == "ACK") {
listen(socket, "message", function (event) {
if (event.data == "SYN") {
socket.send("ACK");
function main() {
listen(socket, "message", function (event) {
if (event.data == "PING") {
socket.send("PONG");
main();
}
});
}
main();
}
});
}
});
});
now we’re getting somewhere. however, the
“somewhere” appears to be
Callback Hell. this is accentuated by the
awkwardly named main function.
...aha! await!
function listen(socket, eventType) {
return new Promise(function (resolve, reject) {
function handler(event) {
socket.removeEventListener("error", reject);
resolve(event);
}
socket.addEventListener("error", reject);
socket.addEventListener(eventType, handler);
};
}
await listen(socket, "open");
socket.send("SYN");
if ((await listen(socket, "message")).data == "ACK") {
if ((await listen(socket, "message")).data == "SYN") {
socket.send("ACK");
while ((await listen(socket, "message")).data == "PING") {
socket.send("PONG");
}
}
}
the rest of the awkward code can be handled by just
using an assert function:
class AssertionFail extends Error {
constructor() {
super("assertion failed");
this.name = "AssertionFail";
}
}
function assert(condition) {
if (!condition) {
throw new AssertionFail();
}
}
async function expect(socket, data) {
let packet = await listen(socket, "message");
assert(packet.data == data);
return packet;
}
await listen(socket, "open");
socket.send("SYN");
await expect(socket, "ACK");
await expect(socket, "SYN");
socket.send("ACK");
while (await expect(socket, "PING")) {
socket.send("PONG");
}
that is the problem with event-driven code.
“what about handling button input (or an animation controller) in a video game, for instance. do you really suggest that i remodel that imperatively?”
absolutely not. that’s a fully justified usage of unstructured code. it handles state in a manner that is much more poorly described imperatively. specifically, with the websocket example, you can imagine each state as a circle, with states containing the states they lead to (in a kind of topological sort). “if one has reached state 2, one had to have been at state 1 at some point in the past.” the back-edges are then represented by various looping constructs.
player input and animators, uh, don’t work this way! the circles are not remotely sorted like that, and are intersecting eachother arbitrarily (if the player is moving and grounded, perform walking animation, but if the player is not moving and grounded, perform idle animation, but if the player is not grounded, perform falling animation). that said, it’s still a bad idea to represent that as a naïve state machine. Animator Hell happens for the same reasons that Callback Hell happens. i’d suggest a system like aarthificial’s reanimation instead (where the state machine is replaced by a decision tree). player input does similar stuff.
“this is impractical, no library uses this interface.”
function curry(offender, ...args) {
return new Promise(function (resolve, reject) {
try {
offender(...args, resolve);
} catch (error) {
reject(error);
}
});
}
EventTarget.prototype.waitFor = function (eventType) {
return new Promise(function (resolve, reject) {
try {
this.addEventListener(eventType, resolve);
} catch (error) {
reject(error);
}
})
};
now almost everything uses it.
“it’s not that bad, this is an overreaction.”
fun fact: some people still write unstructured code, and by some people, i mean a surprising number of them![3]
“why?”
yes.
“what”
Y e s .
this article is one of my submissions to The Writing Gaggle! check them out, they’re cool.