Chapter 3 - Continuing w/ Style
In this chapter, we introduce continuations and writing functions in continuation passing style (CPS). Writing in CPS provide functional programs control flow simillar to that of imperative ones.
1. Continuation Passing Style
In essence, a continuation is a higher-order function that abstracts over an extended context for performing a certain computation. This is much more easily explained in a functional language, since we can treat continuations simply as a special form of accumulator, where the value being “accumulated” is a function. This also has the added benefit of providing control over program evaluation.
a. Callback Everyday – Continuations
As we mentioned, a continuation is a higher-order function, and writing in CPS is synonymous with using a function as an accumulator.
With this in mind, let’s take a few steps back and recall writing in APS. In Chapter 1, we described how to convert a generally recursive definition into one that uses APS. To reiterate, here is the definition of sum
written using general recursion:
sum :: List Int -> Int
sum Nil = 0
sum (x:xs) = x + (sum xs)
To convert sum
to its APS equivalent, we add an accumulator parameter, update its value during the recursive step, then return it once the base case is reached.
sum :: List Int -> Int
sum xs = sumAcc xs 0
where
sumAcc :: List Int -> Int -> Int
sumAcc Nil acc = acc
sumAcc (x:xs) acc =
sumAcc xs (x + acc)
From here, translating an APSed program to a CPSed equivalent requires that we abstract over the accumulator by replacing it with a higher-order function. In this case, sumAcc
has two arguments, a List Int
and an Int
, where the second is the accumulator. Replacing this value with a higher-order function means that we have the following type for sumCPS
:
sumCPS :: List Int -> (... -> ...) -> Int
Let’s complete this type declaration. We need to think about:
- The type of the continuation’s parameter.
- The return type of the continuation.
(1)
and (2)
are synonymous with the type of acc
in sumAcc
. If we inspect the initial value of acc
in the context of sum
, we discover that it’s initialized to the value 0
, an Int
. This means that the first ...
is Int
.
sumCPS :: List Int -> (Int -> ...) -> Int
The second ...
is the type of acc
after being updated during each recursive case. During the recursive case, given that we peform addition on acc
, it should come with no surprise that the type is also Int
.
sumCPS :: List Int -> (Int -> Int) -> Int
Declaring this type dictates the definition of sumCPS
in two ways:
- Since our continuation is acting as an accumulator, we must apply the continuation to our base case,
0
. - We must extend the continuation during the recursive case.
For (1)
, we apply a continuation to the value 0
, the original return value of sum
. For (2)
, we recur normally, while treating our continuation as a form of accumulator. Here, we are not performing the addition to a set value like in sumAcc
but are, instead, creating a function that performs the addition under the context of some other computation, k
, and is eventually applied to 0
when the base case is reached. Thus, extending a continuation is synonymous with defining how sum
continues with its next computation.
>
To further illustrate the behavior of this CPSed function, we include a trace of the List
and continuation value in computing (sum (1:2:3:Nil))
:
-- Recursive case => Continuations are extended
(1:2:3:Nil) id
(2:3:Nil) ((+) 1 >>> id)
(3:Nil) ((+) 2 >>> (+) 1 >>> id)
Nil ((+) 3 >>> (+) 2 >>> (+) 1 >>> id)
-- Base case is reached => Continuations are applied
((+) 3 >>> (+) 2 >>> (+) 1 >>> id) 0
((+) 2 >>> (+) 1 >>> id) 3
((+) 1 >>> id) 5
id 6
-- Final continuation is applied
id 6
6
Aside: id
is our old friend the identity function. Since continuations are functions, to properly model the behavior of an accumulator, their base value must be id
.
b. One Step at a Time – Control Flow
The primary benefit of writing in CPS is the control over a function’s evaluation order. To illustrate this point, we need a more complex example than sum
. Let’s bring back append
and rev
from Chapter 1:
append :: forall a. List a -> List a -> List a
append Nil ys = ys
append (x:xs) ys = x:(append xs ys)
rev :: forall a. List a -> List a
rev Nil = Nil
rev (x:xs) = append (rev xs) (x:Nil)
Let’s CPS both of these functions. First, let’s determine the appropriate types for their CPSed equivalents.
Since neither of these function are written in APS, we must add a continuation parameter to both of their type declarations:
append :: forall a. List a -> List a -> (List a -> List a) -> List a
rev :: forall a. List a -> (List a -> List a) -> List a
In both cases, the type of the continuation parameter is (List a -> List a)
, since both functions return a List a
. Let’s start by implementing the CPSed version of append
.
append Nil ys k = ?basecase
append (x:xs) ys k = ?recur
In the original definition of append
, we return ys
in the event that xs
is Nil
. In the CPSed version, we return ys
by applying the continuation k
to it:
append Nil ys k = k $ ys
In the recursive case, we extend our continuation with the :
operation.
append (x:xs) ys k =
append xs ys ((:) x >>> k)
Next, we implement the CPSed version of rev
:
rev Nil k = ?basecase
rev (x:xs) k = ?recur
Implementing rev
requires that we reason about the order of evaluation, which is a quite bit different from the general functional style of writing code. Since the base case of rev
is straightforward to implement, let’s focus on its recursive case:
rev (x:xs) = append (rev xs) (x:Nil)
Here, we are calling append
and rev
. The question is: Which one should happen first? The natural answer would be that the call to rev
happens first. In general, this is correct, but writing code in the general functional style provides us the liberty of not having to reason about which call happens first!
In CPSing rev
, we gain the ability to choose which call happens first.
>
NOTE: Don’t forget to use id
when calling these functions!
Here, we’ve implemented rev
to first evaluate the recursive call to itself. Doing this, we discover that the recursive call to rev
must happen before the call to append
! This is because append
depends on the result of the rev
computation, which is made clearer when written in CPS style. In fact, it is impossible to properly evaluate the call to append
without having xs'
, the result of the call to rev
.
c. If You Squint Your Eyes – Tail Calls
One might have noticed someting strange about the implementation of fully CPSed functions, especially in the way we’ve written append
and rev
above. This particular something is not actually strange at all but instead is one of the other benefits of writing in CPS.
In a CPSed function, every call is a tail call. This is a side-effect of regaining control over the flow of program execution. That is, the execution of each line happens right when we expect them to, just like it would in an imperative language! Let’s focus again on the recursive case in rev
:
rev (x:xs) k =
rev xs $ \xs' ->
append xs' (x:Nil) k
Then, let’s add a bit of whitespace, η-expand and rename our continuation variable, k
:
rev (x:xs) Nil return =
rev xs $ \xs' ->
append xs' (x:Nil) $ \ans ->
return ans
Whoa. Doesn’t that look familiar?
In this manner, looking at a CPS function (almost) feels analogous to looking at an implementation in an imperative language. That is, in the recursive case of rev
, we know that the following happens:
- The call to
rev
is executed. - Once
(1)
finishes, the current continuation is applied to its result. - From
(2)
, the variablexs'
holds the value of(rev xs)
. - After the continuation is applied in
(2)
, the code continues with the next line. - Once
(4)
finishes, the current continuation is applied to its result. - From
(5)
, the variableans
holds the value of(append xs' (x:Nil))
. - After the continuation is applied to
(4)
, the code continues with the next line. - Finally, applying
return
toans
results in program exit.
2. CPS the Interpreter – Implementation
We’re bringing interpreters back!
In this section, we’ll implement a CPSed interpreter by making changes to interpD
from Chapter 2. With the complexity of an interpreter, we are able to further illustrate the power of having explicit control over the flow of program evaluation.
We’ll start with a few changes in the Closure
type and the ValueD
Type:
Then we’ll implement the rest of the interpreter in the same way we did in Chapter 2:
- Number
Value
d expressions - Boolean and Branching expressions
- λ-calculus expressions
Let’s begin!
a. Number Valued Expressions
Let’s recall our previous implementation for number Value
d expressions:
interpD _ (Num i) = ND i
interpD e (Sub x y) = ND (calcValueD e (-) x y)
interpD e (Mul x y) = ND (calcValueD e (*) x y)
Here, we defined calcValue
to handle the special case of Sub
and Mul
, which is defined as:
on :: forall a b c.
(b -> b -> c) -> (a -> b) ->
a -> a -> c
on op f x y = f x `op` f y
calcValue :: Env Value -> (Number -> Number -> Number) ->
Term -> Term -> Number
calcValue e op =
on op $ \x ->
case interp e x of
N num -> num
_ ->
error "arithmetic on non-number"
calcValue
also used the function on
to apply the function that interp
s to both sub-expressions of Sub
and Mul
and pattern matches on their (N i)
case.
Before we CPS these functions for our new interpreter, let’s take the time to reason about what happens in each of them and the order that they should occur.
For on
:
- A function,
f
, is applied to bothx
andy
. - An
op
function is applied to the results of(1)
. - The result of
(2)
is returned.
Thus, to CPS on
, we must first apply an f
to an x
and y
before returning the result of applying an op
to their results. This gives us:
onC op f x y return =
f x $ \x ->
f y $ \y ->
return $ x `op` y
We’ll get back to properly declaring a type for onC
. First, let’s take a look at what calcValue
does:
- Using
on
, twoTerm
s are passed to a function. - This function takes
Term
, passes it tointerp
, determines whether or not itsValue
represents a number. - If the
Value
is a number, the number is returned. - Otherwise, an error is raised.
This gives us:
calcValueC e op =
onC op $ \a return ->
interpC e a $ \a ->
case a of
NC num -> return num
_ ->
error "arithmetic on non-number"
Providing the proper type for calcValueC
is rather straightforward since its specialized to only handle certain inputs. Originally, calcValue
takes an Env
, an op
function, two Term
s, and returns Number
. In this case, however, after CPSing, it returns a ValueC
!
calcValueC :: Env ValueC -> (Number -> Number -> Number) ->
Term -> Term -> (Number -> ValueC) -> ValueC
After we CPSed calcValue
, we revealed that its return value depends on the result of a call to interp
, which always returns a Value
. This dicates the type of the continuation passed to calcValueC
, which consequently affects the type of the continuation in onC
. This is why, due to its complexity, we held off providing the type declaration for onC
. Given that onC
is used in the context of calcValueC
, its type declaration must provide the details for every context it can be used in.
To properly derive the type of onC
, let’s recall its non-CPSed type declaration:
on :: forall a b c.
(b -> b -> c) -> (a -> b) ->
a -> a -> c
When we derive the type of onC
, we must do the following:
- Add an additional polymorphic variable, representing the function’s return type.
- Every CPSed function argument must also return this type.
- The function itself must also return the polymorphic return type.
For (1)
, let’s add the type variable r
to represent the return type of onC
.
forall a b c r.
(b -> b -> c) -> (a -> b) ->
a -> a -> c
Then, with (2)
, we must determine which function arguments passed to onC
are CPSed. In this case, f
, the second function argument, is CPSed since it includes a call to our CPSed interpreter, interpC
.
This means there are two functions that must return the type r
:
f :: a -> b
on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
First, let’s fix the type for f
. Since f
originally returned a b
, this means that its CPSed equivalent will have a continuation of type (b -> r)
and return an r
:
f :: a -> (b -> r) -> r
Lastly, we need to change the type of onC
itself. Since on
originally returned a c
, this means that onC
will have a continuation of type (c -> r)
and return an r
.
onC :: forall a b c r.
(b -> b -> c) -> (a -> (b -> r) -> r) ->
a -> a -> (c -> r) -> r
This completes the definitions for onC
and calcValueC
:
We then use calcValueC
to define the number Value
d expressions of our interpreter:
interpC _ (Num i) return =
return $ NC i
interpC e (Sub x y) return =
calcValueC e (-) x y $
return <<< NC
interpC e (Mul x y) return =
calcValueC e (*) x y $
return <<< NC
b. Boolean and Branching Expressions
Next, let’s handle the cases for IsZero
and If
. In interpD
, these cases were defined as follows:
interpD e (IsZero x) =
BD $ interpD e x == ND 0.0
interpD e (If x y z) =
case interpD e x of
BD b | b -> interpD e y
| otherwise -> interpD e z
_ -> interpD e y
Let’s use the same strategy we employed in defining the cases for number Value
d expressions for the above.
First, let’s reason about what happens in each case:
IsZero
:
interp
the sub-expressionx
.- Determine whether the result of
(1)
is the value0
. - The result of
(2)
is wrapped with theBoolean
Value
constructor. - The result of
(3)
is returned.
If
:
interp
the sub-expressionx
.- Determine the truthiness of the result of
(1)
. interp
the appropriate sub-expression (y
orz
).
Which gives us the following:
interpC e (IsZero x) return =
interpC e x $
return <<< BC <<< ((==) $ NC 0.0)
interpC e (If x y z) return =
interpC e x $ \x ->
case x of
BC boo
| boo -> interpC e y return
| otherwise -> interpC e z return
_ -> interpC e y return
c. λ-calculus Expressions
And then there were three.
To compare, let’s recall how λ-expressions were handled by interpD
:
interpD e (Var n) = lookUp e n
interpD e (Lam n b) = FD $ makeClosure n b e
interpD e (App l r) = case interpD e l of
FD foo -> applyClosure foo (interpD e r)
_ -> error "applied non function value"
Let’s start with the Var
case. Since, lookUp
is not a CPSed function, we can simply return its result.
interpC e (Var x) return =
return $ lookUp e x
If we had CPSed lookUp
, we would be required to first evalaute the call to lookUp
, then return its result, which would look like this:
lookUp e x return
Next, let’s implement the case for Lam
expressions. In interpD
, we used makeClosure
to create a Closure
, then wrapped it in the FD
constructor. For interpC
, we’ve chosen not to CPS makeClosure
, thus we simply return the result of applying FC
to a call to makeClosure
:
interpC e (Lam n b) return =
return $ FC (makeClosure n b e)
Lastly, let’s implement the case for App
expressions. In interpD
, this case is a bit more complex than the cases for Var
and Lam
. For the App
case, the following occurs:
interp
the sub-expressionl
.- Determine if the result of
(1)
is a function value. - If
(1)
is a function,interp
the sub-expressionr
. Otherwise, raise an error. - When
(3)
suceeds, the value of(1)
and(3)
are passed toapplyClosure
.
Before we can CPS the case for App
, we need to determine whether or not applyClosure
should be a CPSed function or not. Let’s recall its original implementation:
applyClosure :: Closure -> ValueD -> ValueD
applyClosure (Closure clos) val =
interpD (extend clos.name val clos.env) clos.body
We discover that applyClosure
calls an interp
function. Since our interp
function is CPSed, applyClosure
must also be CPSed. Luckily, this function is rather straightforward to implement. All we need to do is change its type to include a continuation parameter, then thanks to η-reduction, the new applyClosure
looks almost exactly the same! This is because the call to interp
in applyClosure
is already a tail-call.
We then implement the App
case as described above:
interpC e (App l r) return =
interpC e l $ \l ->
case l of
FC foo ->
interpC e r $ \val ->
applyClosure foo val return
_ -> error "applied non-function value"
And that’s all, folks! We’ve successfully CPSed our interpreter!
>
One should be able to use this interpreter on every example Term
from Chapter 2. Just remember to pass it an empty continuation in addition to its regular arguments!
We include a sample trace for evaluating the Term
:
(App (Lam "x" (Lam "y" (Var "x"))) (Num 6.0))
interpC EmptyEnv (App (Lam "x" (Lam "y" (Var "x"))) (Num 6.0)) id
==
interpC EmptyEnv (Lam "x" (Lam "y" (Var "x"))) $ \l ->
case l of
FC foo ->
interpC EmptyEnv (Num 6.0) $ \val ->
applyClosure foo val id
_ -> error "applied non-function value"
== cont is applied
\l ->
case l of
FC foo ->
interpC EmptyEnv (Num 6.0) $ \val ->
applyClosure foo val id
_ -> error "applied non-function value" $
FC (makeClosure "x" (Lam "y" (Var "x")) EmptyEnv)
==
case FC (Closure {name:"x",body:(Lam "y" (Var "x")),env:EmptyEnv}) of
FC foo ->
interpC EmptyEnv (Num 6.0) $ \val ->
applyClosure foo val id
_ -> error "applied non-function value"
==
interpC EmptyEnv (Num 6.0) $ \val ->
applyClosure
(Closure {name:"x",body:(Lam "y" (Var "x")),env:EmptyEnv})
val id
==
\val ->
applyClosure
(Closure {name:"x",body:(Lam "y" (Var "x")),env:EmptyEnv})
val id $
NC 6.0
==
applyClosure
(Closure {name:"x",body:(Lam "y" (Var "x")),env:EmptyEnv})
(NC 6.0) id
==
interpC (extend "x" (NC 6.0) EmptyEnv) (Lam "y" (Var "x")) id
==
id $
FC (makeClosure "y" (Var "x") (Ext {name:"x",val:(NC 6.0)} EmptyEnv))
==
FC (makeClosure "y" (Var "x") (Ext {name:"x",val:(NC 6.0)} EmptyEnv))
> Function from y returns x, with context {x: (NC 6.0)}
Exercises:
- Define a CPSed
fact
function.
>
- Define a CPSed
ack
function.
>
- Define a CPSed
fib
function.
>
Too easy? How about these:
- Define a CPSed
map
function and derive its type.
>
- Define a CPSed
filter
function and derive its type.
>
- Define a CPSed
foldList
function and derive its type.
>
Why no tests? How about this one?
>
testCPS
will trigger a type error if the types of mapC
, filterC
and foldListC
are incorrect!
Here are some sample functions to pass to testCPS
–feel free to add a few of your own!
Try testCPS
with:
testCPS fibFactC lte120 0 addC (1..5) id
Which does the following:
fibFactC
is mapped over the list(1..5)
.- All elements greater than
120
is removed from the result of(1)
. - The resulting list from
(2)
is summed.