Coroutines
As a final example of programming in CPS, let us replicate the implementation of coroutines via continuations that we illustrated using this piece of code before:
(define (lexy n yield)
(call/cc (lambda (continue) (yield (cons '() continue))))
(unless (zero? n)
(map (lambda (c)
(let ((result (call/cc (lambda (yield) (lexy (- n 1) yield)))))
(when (car result)
(yield (cons (cons c (car result)) (cdr result))))))
'(#\0 #\1)))
(cons #f #f))
(let ((result (call/cc (lambda (yield) (lexy 3 yield)))))
(when (car result)
(display (list->string (car result)))
(newline)
((cdr result))))
In the Scheme code, lexy
signals to the caller that it is done producing
values by returning a pair whose first component is #f
. Otherwise, the first
component of this pair is a list of characters. This works in Scheme because it
is dynamically typed. In Haskell, lexy
needs to return a value of the same
type no matter whether it actually produces a value or it has no more values to
produce. The natural way to represent this is that lexy
yields
Maybe (String, r)
. This value is Nothing
when lexy
is done iterating.
Otherwise, it is Just
a pair composed of the string produced by lexy
and a
continuation of type r
that we can invoke to resume lexy
.
To allow lexy
to yield such a value, lexy
needs to be
given a continuation which it can call with this value. This makes lexy
have
the following type:
lexy :: Int -> (forall r. (Maybe (String, r) -> r) -> r)
Here, it isn't as essential to unpack the function of type Maybe a -> r
into a
pair of functions of type r
and a -> r
, as we did in our implementation of
mapCPS
, but there is a certain elegance to having lexy
choose the right
continuation directly instead of signalling its intention using a value of type
Maybe String
, and then the continuation implements a case distinction based on
this value. So let's replace the function of type Maybe (String, r) -> r
with
two functions of type r
and (String, r) -> r
or, unpacking the pair
(String, r)
with two functions of type r
and String -> r -> r
.
lexy :: Int -> (forall r. r -> (String -> r -> r) -> r)
The driver code of the Scheme program is the let
block in the last five lines
of that code. This is essentially a loop. Every time lexy
yields a value, the
body of this loop should run. At the end of each iteration, we invoke the
continuation returned by lexy
to resume it. When lexy
is done iterating, the
main code should exit.
In Haskell, printing strings on screen requires the IO
monad. So it is natural
to choose r = IO ()
. We make the main
function exit by having lexy
invoke
the continuation return ()
. The continuation that lexy
should use to yield a
value is a function loop
that implements the loop
body:
main :: IO ()
main = lexy 3 (return ()) loop
where
loop str resume = do
putStrLn str
resume
Here's the skeleton for implementing lexy
:
lexy :: Int -> r -> (String -> r -> r) -> r
lexy n done yield = ...
n
is the maximum length of the strings to be produced by lexy
. done
is the
continuation that lexy
should invoke to signal that it's done. yield
is the
continuation it uses to yield a string and a continuation of type r
back to
its caller.
The first thing that lexy
should always do no matter the value of n
is to
yield the empty string and a continuation that allows lexy
to be resumed. We
implement this continuation using a function lexy'
:
lexy :: Int -> r -> (String -> r -> r) -> r
lexy n done yield = yield "" lexy'
where
lexy' = ...
If n == 0
, then lexy
shouldn't do anything after yielding the empty string.
Thus, lexy' 0
simply invokes done
to signal that it's done:
lexy :: Int -> r -> (String -> r -> r) -> r
lexy n done yield = yield "" lexy'
where
lexy' = case n of
0 -> done
_ -> ...
For all other values of n
, we want to start a loop that iterates over all
characters in the alphabet "01"
. Since that's the outer loop of a pair of
nested loops, we call it outerLoop
and call it with the alphabet to be
iterated over:
lexy :: Int -> r -> (String -> r -> r) -> r
lexy n done yield = yield "" lexy'
where
lexy' = case n of
0 -> done
_ -> outerLoop "01"
outerLoop
implements this iteration using tail-recursion. In general, the
argument of outerLoop
is the list of characters in the alphabet we haven't
iterated over yet. Thus, once that list of characters is empty, the loop is
done, that is, lexy
is done. We invoke the done
continuation to signal this:
lexy :: Int -> r -> (String -> r -> r) -> r
lexy n done yield = yield "" lexy'
where
lexy' = case n of
0 -> done
_ -> outerLoop "01"
outerLoop "" = done
If there are still characters to iterate over, we split the list of characters into the first character in this list and the list of the remaining characters:
lexy :: Int -> r -> (String -> r -> r) -> r
lexy n done yield = yield "" lexy'
where
lexy' = case n of
0 -> done
_ -> outerLoop "01"
outerLoop "" = done
outerLoop (x:xs) = ...
x
is the character of interest in the current iteration. What the current
iteration should do is to iterate over the strings yielded by lexy (n - 1)
.
For each such string str
, we yield x:str
and the resume
continuation
yielded by lexy (n - 1)
back to the caller of lexy n
. We implement this
logic in a function innerLoop
that implements the body of this inner loop:
lexy :: Int -> r -> (String -> r -> r) -> r
lexy n done yield = yield "" lexy'
where
lexy' = case n of
0 -> done
_ -> outerLoop "01"
outerLoop "" = done
outerLoop (x:xs) = lexy (n - 1) ??? innerLoop
where
innerLoop str resume = yield (x:str) resume
What should the ???
be? That's the continuation that lexy (n - 1)
should
invoke to signal that it is done iterating. When that's the case, we need to
start another iteration of outerLoop
, which we do by invoking outerLoop
with
the remaining characters in the alphabet, xs
:
lexy :: Int -> r -> (String -> r -> r) -> r
lexy n done yield = yield "" lexy'
where
lexy' = case n of
0 -> done
_ -> outerLoop "01"
outerLoop "" = done
outerLoop (x:xs) = lexy (n - 1) (outerLoop xs) innerLoop
where
innerLoop str resume = yield (x:str) resume
And with that, we have a Haskell version of lexy
implemented in CPS, just as
in Scheme. Let's try it out:
{-# LANGUAGE RankNTypes #-}
main :: IO ()
main = lexy 3 (return ()) loop
where
loop str resume = do
putStrLn str
resume
lexy :: Int -> (forall r. r -> (String -> r -> r) -> r)
lexy n done yield = yield "" lexy'
where
lexy' = case n of
0 -> done
_ -> outerLoop "01"
outerLoop "" = done
outerLoop (x:xs) = lexy (n - 1) (outerLoop xs) innerLoop
where
innerLoop str resume = yield (x:str) resume
>>> :l Lexy.hs
[1 of 1] Compiling Main ( Lexy.hs, interpreted )
Ok, one module loaded.
>>> main
0
00
000
001
01
010
011
1
10
100
101
11
110
111
This was quite a journey. Programming in CPS is not as unnatural as it may seem at first, but we would surely prefer to program using standard functions that return results instead of having to pass a continuation to every function, and then the function calls the continuation for us. In Scheme, we can do this while also using continuations when we need them. In order to the same in Haskell, we need a monad. That's what we discuss next.