Delimited continuations

Posted on February 3, 2020 by Calvin

Some of the most amazing quality-of-life features of many programming languages are centered around control flow. For example, lazy-evaluated iterators in the form of generators in Python, async/await for asynchronous control, etc. are all ways to improve the control flow in a language. However, as more disciplines become more computational, it is becoming apparent that new types of control flow operators are necessary. For example, modern machine learning relies heavily on reverse-mode autodifferentiation, which in many languages is implemented as a secondary DSL embedded in the host language.

Wouldn’t it be great if our languages had an abstraction that allowed us to build any control flow operator we wanted? These atomic objects are known a delimited continuations. Recall that a continuation is merely an abstraction of “the rest of a computation”. For example, consider the following computation

if x == 0 then 1 + 3 else 4

After the evaluation of x == 0, its continuation is the remaining holed-computation

if [] then 1 + 3 else 4

However, these aren’t really true continuations! Philosophically, the continuation extends beyond the code, to the effects generated by the hardware, etc. Hence, we really want to consider continuations restricted to a certain point. These are delimited continuations, and these are the objects that can be reified into functions in the host language. Delimited continuations allow us to manipulate the control flow of our programs. And although monads allow us to control the sequential flow of computational effects in our programs, delimited continuations give us the flexibility to do more with that control.

shift/reset

Delimited continuations are realized in a language via control operators, which allow us to capture both the scope of the continuation and to express the flow of the program. The most written about operators are shift and reset, though there are others in the PLT literature.

First we recall continuations in Haskell. A continuation is a suspended computation, that is, to resume it we need to give it a callback. Hence the type is given by

To understand the monadic instance, it might be easier to see what happens in CPS (Python):

In Haskell, this same inversion of control is explicit in the monad instance:

Next we turn our heads towards the control operators shift/reset. The semantics are fairly complicated, so it suffices to look at an example. Lets look at the expression

1 + reset (2 * (shift \k -> k (k 5)))

The way to interpret this is to look at the bound variable k in shift. It is bound to the continuation outside the shift, delimited by the reset, which in this case is given by 2 * []. Hence k 5 is 5 applied to this continuation, so 10. Applying k again gives 20, so we replace the above with 1 + 20 == 21.

Written out, we can express these two control operators in terms of the continuation monad:

reset wraps a computation to set the boundaries of the delimited continuation. Once the computation inside finishes, it returns the rest of the computation outside of the reset statement. The shift operator is a bit trickier:

The input to shift is a function that takes a callback function to form a computation. All shift does is run this continuation! By itself, this does nothing– there isn’t any control flow present here. However when combined with reset, the callback k is bound to the “continuation delimited by reset”.

As an example, we use the computation above, translated to Haskell:

Notice the Scheme-esque parentheses and operators! Anyway, it’s fairly straightforward to lift these things. Evaluating with runC we get our desired answer.

exceptions as control

People who use Python (I refuse to use the neologism for them) are familiar continuations even though they aren’t first-class objects in the language– they use exceptions! Exceptions capture the control stack at a future time and allows one to swap out for that stack completely when an error is raised.

This effect can be replicated via delimited continuations. Throwing an exception can be thought of as interrupting a computation in a try-block, forcing the short-circuiting of the block. As above, this is a shift-block where the callback is ignored, and instead control is handed directly to a handler object:

Of course, a throw statement is useless without a handler to deal with it (this is the framework of algebraic effects that I’ll talk about in a later post). This is given by a try-except block: The computation occurs in the try statement until a throw statement is encountered. In this case, the computation’s continuation in the try block is ignored, and control is completely given to the handler. This is a delimited continuation, where the try is contained in a reset!

However, this is a bit tricky to write. To simplify matters, lets look at how we can just exit from a computation early (without handling the output). Just like the throw statement, an early exit in a delimited continuation is a shift-block:

Running the above results in 0, which is the exit value. However, the exit value is undistinguished from a normal value, which is bad since thrown exit values should be passed to the exception handler, while normal values ignore it. We could distinguish it by creating a custom type, or we can “functionalize” the continuation. This trick gives the try-except block:

As an example of how it works, look at

Running example1 gives 21, as nothing is thrown. However, in example2 a throw statement is called with the exception value 4, which is handled in the handler \e -> e + 42 giving us 46 as the final value.

the yield statement

Our delimited continuations are awesome, and we wish languages had a fun way to expose these objects without turning to continuation-passing style (CPS). (Yes, the Cont monad is CPS– code written wrapped in the Cont-context is implicitly in CPS). It turns out that languages that expose a yield primitive actually have a way to access delimited continuations! This is the central result of the paper by James-Sabry “Yield: Mainstream delimited continuations”, written by two professors at IU. For some reason I never really spent any time near Lindley Hall (the CS department) even though Rawles was literally across the street…

Since yield statements are used in Python generators, let’s explore how to build a generator with continuations. Recall that a generator is effectively a lazy iterator. What this entails is that once a generator is called to output a value, it suspends until the next time it is called. This should immediately scream reified continuation! A generator will output not only a value, but also keeps track of the reified continuation corresponding to the remainder of the iterator. When the generator resumes, it merely starts over by running this continuation object.

We can capture these continuations using the reset/shift control operators. Consider an example:

We see that in this, whenever the expression k <$> (return []) is reached, a continuation is reified, and that continuation object represents the rest of the iteration! When we run this generator we get back [1,2,3]. We abstract the repeated statement– this is a yield:

This yield statement has the right credentials: calling it suspends the computation by reifying the remainder of the computation, which exists outside of the shift statement. With this operator, we get the generator as:

The main insight of the above paper is a generator (with yield statement) entirely encapsulates a delimited continuation, where each yield statement is equivalent to a shift statement. In this way, the reset block tells us how to run the generator. This can be extracted in familiar language as run:

We see that running gen gives back our [1,2,3] as desired.

python?

Since Python generators are so prevalent, could we use them like delimited continuations? As an experiment, I wanted to try replicating the above exception handling. However I ran into an issue– generators are not really designed to be flattened. I needed multiple layers of generators, and the yield statements in Python seems to only capture the continuation in its base generator.

This presents a small issue. It seems like Python generators and its built-in try/except exception handling are orthogonal abstractions! This is exploitable, and is the focus of one of my favorite papers in computer science: thermometer continuations. This is the content of my next post.