"You are responsible for explicitly understanding the rest of the program." A sentence like this appears in every tutorial explaining first-class continuations, a programming language construct that appears in Lisp, Scheme, and Haskell, and if you look at people discussing them they find such constructs "scary."
One of the things I did many, many moons ago was write a couple of small Lisp
interpreters. (It was long enough ago that I'm
sorry to say they were written in CoffeeScript.) One thing that I did
finally understand while building these was how continuations could be used to replace almost all
flow control, that if
, while
, and even function calls were all just specialized versions of
continuations.
The problem with continuations is that they do look scary. But while watching Alexis King's awesome presentation Demystifying Delimited Continuations, I'm glad to say that I finally wrapped my head around the notation for continuations in a way that clarifies that terrifying sentence you find in every explanation of continuations, "A continuation is the code you write that will handle the rest of the program."
In Alexis's example, she writes about try/catch, and she describes it this way. When you call a
function, it is run in an "evaluation context" which includes all free variables, those defined
globally or within in a parent scope, which is absolutely necessary because some of those variables
are function names you want to call. Let's call this evaluation context 𝐄
. It contains
everything outside the current function which that function needs to evaluate correctly. Let's
annotate this function call this way: 𝐄[f()]
.
Alexis describes try/catch in the scenario where f1
throws an exception, using this notation where
the try
part defines an inner continuation and catch
defines what to do with the value that
inner continuation generates:
𝐄[catch{𝐄₂[f1()], f2}]
𝐄[catch{𝐄₂[throws(v)], f2}]
𝐄[f2(v)]
As far as the function f1()
in concerned, the continuation 𝐄₂
is "the rest of the program," but
notice what the continuation really is: it's just an purely boolean if
statement. It says that if
the return value of the left is as exceptional value, execute the function on the right with that
value within the context of some parent execution context 𝐄
.
Try/Catch vs Result
In Rust, 𝐄₂
would be function that calls a function with a Result<Ok, Error>
return value:
pub fn sqrt(x: f64) -> Result<f64, MathError> {
if x < 0.0 {
Err(MathError::NegativeSquareRoot)
} else {
Ok(x.sqrt())
}
}
The receiving function would be responsible for choosing what to do with each value.
... and that's it. That's all they are. This is why I like Rust a lot; it makes explicit all that painful "stack rewinding" which was nothing more than a convenience method for letting people out of deep libraries that were failing. Rust has taught us all that "deep libraries" were, by themselves, a mistake. The Linux world used to say "With enough eyes, all bugs are shallow," but in this era of explosive growth and constant innovation, there just aren't enough eyes trained well enough to evaluate. Each library should have a facade, the API boundary layer, that it exposes to the world, but each library should also, on its own, be no deeper than it absolutely must be to maintain a coherent story in the face of failure.
Looking through the effort Alexis went
through to get continuations added to
Haskell, there's obviously a lot more to it than that. In our toy example, try
creates a place on
the stack where we can "rewind" to in an emergency, but the whole rest of the stack is represented
by 𝐄
. How you want to preserve that stack, how large you want that preserved stack to be, when you
give a continuation a name and allow it to be passed out of the current execution frame, is what
makes "first class" continuations so difficult for people to understand.
You're probably familiar with Jason Heeris's comic, Why You Should Never Interrupt Your Programmer, because he's busy holding a huge amount of context in his head, simulating as much of the program as he needs to, to get the job done. I've found that making the job as small as possible is how you avoid this problem, by being explicit about your levels of abstraction, and the levels of responsibility in code that you're currently working on. By avoiding having to know more than "what's going on one level up," you keep yourself sane and productive. Understanding continuations helps by making explicit what that one level up is, even if you don't actually use continuation-specific syntax or semantics in your language of choice.
Postscript: I just want to add that if you're using a language without stack frames, or a
language with [dynamic scoping] (Emacs Lisp, Bash, PowerShell), you're kinda on your own. Those are
a whole different beast. Emacs Lisp, at least, recognized just how maddening dynamic binding is, and
made lexical binding an "option" in the same sense than use strict
is an "option" in JavaScript:
sure it's "optional," in that everything written after its introduction must use it or your linter
will be very cross with you.