Understanding continuations
How 'let' works behind the scenes
In the previous post we saw how some complex code could be condensed using computation expressions.
Here's the code before using a computation expression:
And here's the same code after using a computation expression:
The use of let!
rather than a normal let
is important. Can we emulate this ourselves so we can understand what is going on? Yes, but we need to understand continuations first.
Continuations
In imperative programming, we have the concept of "returning" from a function. When you call a function, you "go in", and then you "come out", just like pushing and popping a stack.
Here is some typical C# code which works like this. Notice the use of the return
keyword.
You've seen this a million times, but there is a subtle point about this approach that you might not have considered: the called function always decides what to do.
For example, the implemention of Divide
has decided that it is going to throw an exception. But what if I don't want an exception? Maybe I want a nullable<int>
, or maybe I am going to display it on a screen as "#DIV/0". Why throw an exception that I am immediately going to have to catch? In other words, why not let the caller decide what should happen, rather the callee.
Similarly in the IsEven
example, what am I going to do with the boolean return value? Branch on it? Or maybe print it in a report? I don't know, but again, rather than returning a boolean that the caller has to deal with, why not let the caller tell the callee what to do next?
So this is what continuations are. A continuation is simply a function that you pass into another function to tell it what to do next.
Here's the same C# code rewritten to allow the caller to pass in functions which the callee uses to handle each case. If it helps, you can think of this as somewhat analogous to a visitor pattern. Or maybe not.
Note that the C# functions have been changed to return a generic T
now, and both continuations are a Func
that returns a T
.
Well, passing in lots of Func
parameters always looks pretty ugly in C#, so it is not done very often. But passing functions is easy in F#, so let's see how this code ports over.
Here's the "before" code:
and here's the "after" code:
And also, in the isEven
example, I wrote aNumber |> ifEven
and aNumber |> ifOdd
. This makes it clear that we are piping the current value into the continuation and the continuation is always the very last step to be evaluated. We will be using this exact same pattern later in this post, so make sure you understand what is going on here.
Continuation examples
With the power of continuations at our disposal, we can use the same divide
function in three completely different ways, depending on what the caller wants.
Here are three scenarios we can create quickly:
pipe the result into a message and print it,
convert the result to an option using
None
for the bad case andSome
for the good case,or throw an exception in the bad case and just return the result in the good case.
Notice that with this approach, the caller never has to catch an exception from divide
anywhere. The caller decides whether an exception will be thrown, not the callee. So not only has the divide
function become much more reusable in different contexts, but the cyclomatic complexity has just dropped a level as well.
The same three scenarios can be applied to the isEven
implementation:
In this case, the benefits are subtler, but the same: the caller never had to handle booleans with an if/then/else
anywhere. There is less complexity and less chance of error.
It might seem like a trivial difference, but by passing functions around like this, we can use all our favorite functional techniques such as composition, partial application, and so on.
The success function takes the email as a parameter and the error function takes a string. Both functions must return the same type, but the type is up to you.
And here is a simple example of the continuations in use. Both functions do a printf, and return nothing (i.e. unit).
Continuation passing style
To see the difference, let's look at the standard, direct style of programming.
When you use the direct style, you go "in" and "out" of functions, like this
In continuation passing style, on the other hand, you end up with a chain of functions, like this:
There is obviously a big difference between the two styles.
In the direct style, there is a hierarchy of functions. The top level function is a sort of "master controller" who calls one subroutine, and then another, deciding when to branch, when to loop, and generally coordinating the control flow explicitly.
In the contination passing style, though, there is no "master controller". Instead there is a sort of "pipeline", not of data but of control flow, where the "function in charge" changes as the execution logic flows through the pipe.
Continuations and 'let' ##
So how does all this fit in with let
?
Remember that a (non-top-level) "let" can never be used in isolation -- it must always be part of a larger code block.
That is:
really means:
And then every time you see the x
in the second expression (the body expression), substitute it with the first expression (someExpression
).
So for example, the expression:
really means (using the verbose in
keyword):
Now funnily enough, a lambda looks very similar to a let
:
and if we pipe in the value of x
as well, we get the following:
Doesn't this look awfully like a let
to you? Here is a let and a lambda side by side:
They both have an x
, and a someExpression
, and everywhere you see x
in the body of the lambda you replace it with someExpression
. Yes, the x
and the someExpression
are reversed in the lambda case, but otherwise it is basically the same thing as a let
.
So, using this technique, we can rewrite the original example in this style:
When it is written this way, you can see that we have transformed the let
style into a continuation passing style!
In the first line we have a value
42
-- what do we want to do with it? Let's pass it into a continuation, just as we did with theisEven
function earlier. And in the context of the continuation, we will relabel42
asx
.In the second line we have a value
43
-- what do we want to do with it? Let's pass it too into a continuation, calling ity
in that context.In the third line we add the x and y together to create a new value. And what do we want to do with it? Another continuation, another label (
z
).Finally in the last line we are done and the whole expression evaluates to
z
.
Wrapping the continuation in a function
Let's get rid of the explicit pipe and write a little function to wrap this logic. We can't call it "let" because that is a reserved word, and more importantly, the parameters are backwards from 'let'. The "x" is on the right hand side, and the "someExpression" is on the left hand side. So we'll call it pipeInto
for now.
The definition of pipeInto
is really obvious:
Note that we are passing both parameters in at once using a tuple rather than as two distinct parameters separated by whitespace. They will always come as a pair.
So, with this pipeInto
function we can then rewrite the example once more as:
or we can eliminate the indents and write it like this:
You might be thinking: so what? Why bother to wrap the pipe into a function?
The answer is that we can add extra code in the pipeInto
function to do stuff "behine the scenes", just as in a computation expression.
The "logging" example revisited ###
Let's redefine pipeInto
to add a little bit of logging, like this:
Now... run that code again.
What is the output?
This is exactly the same output as we had in the earlier implementations. We have created our own little computation expression workflow!
If we compare this side by side with the computation expression version, we can see that our homebrew version is very similar to the let!
, except that we have the parameters reversed, and we have the explicit arrow for the continuation.
The "safe divide" example revisited ###
Let's do the same thing with the "safe divide" example. Here was the original code:
You should see now that this "stepped" style is an obvious clue that we really should be using continuations.
Let's see if we can add extra code to pipeInto
to do the matching for us. The logic we want is:
If the
someExpression
parameter isNone
, then don't call the continuation lambda.If the
someExpression
parameter isSome
, then do call the continuation lambda, passing in the contents of theSome
.
Here it is:
With this new version of pipeInto
we can rewrite the original code like this:
We can clean this up quite a bit.
First we can eliminate the a
, b
and c
, and replace them with the divideBy
expression directly. So that this:
becomes just this:
Now we can relabel a'
as just a
, and so on, and we can also remove the stepped indentation, so that we get this:
Finally, we'll create a little helper function called return'
to wrap the result in an option. Putting it all together, the code looks like this:
Again, if we compare this side by side with the computation expression version, we can see that our homebrew version is identical in meaning. Only the syntax is different.
Summary
In this post, we talked about continuations and continuation passing style, and how we can think of let
as a nice syntax for doing continuations behind scenes.
So now we have everything we need to start creating our own version of let
. In the next post, we'll put this knowledge into practice.
Last updated
Was this helpful?