Coroutines
Our second example is the implementation of lexy, now using the Cont r
monad. Since this example involves performing I/O to print the strings produced
by lexy on screen, we can't use the Cont monad. However, there also exists a
transformer version of it:
newtype ContT r m a = Cont { runContT :: (a -> m r) -> m r }
Interestingly, for ContT to be a monad, we don't even need m to be a monad,
and the implementations of return and (>>=) for ContT r m are identical
to those for Cont r.
Here, we choose the monad ContT r IO, as we want to equip the IO monad with
continuations.
import Control.Monad (forM_, when)
import Control.Monad.Cont (ContT, runContT, callCC)
import Control.Monad.IO.Class (liftIO)
main :: IO ()
main = runContT prog return
prog :: ContT r IO ()
prog = do
str <- callCC (lexy 3)
case str of
Nothing -> return ()
Just (xs, resume) -> do
liftIO $ putStrLn xs
resume
lexy :: Int
-> (Maybe (String, ContT r m ()) -> ContT r m ())
-> ContT r m (Maybe (String, ContT r m ()))
lexy n yield = do
callCC (\resume -> yield $ Just ("", resume ()))
when (n > 0) $ do
forM_ "01" $ \x -> do
nxt <- callCC (lexy (n - 1))
case nxt of
Nothing -> return ()
Just (xs, resume) -> yield $ Just (x:xs, resume)
return Nothing
The main challenge with this implementation is the rather daunting type of
lexy. Let's ignore it for now and focus on how the implementation works.
prog requests the first string using callCC (lexy 3). lexy takes two
arguments n and yield. n is the length of the strings to be generated.
yield is the function lexy can call to yield a string back to its caller.
Only lexy doesn't simply yield a string. It yields a value of type
Maybe (String, Cont r ()). If lexy yields (actually returns, as we will
see) Nothing, then that's the indication that we're done iterating. So prog
exits itself in this case. If lexy yields Just a pair (xs, resume), then
xs is the string produced by lexy, and resume is an action of type
ContT r IO () that can be called to resume lexy. After being resumed, lexy
will yield another value, that is, each time we call resume, this will make
callCC return once more, with another string to be printed, until we finally
obtain Nothing, at which point prog exits.
main simply runs prog, using return (the identity function of any monad)
to extract the value returned by prog.
Now the implementation of lexy. We said already that n is the length of the
strings to be generated, and yield is the function that lexy can call to
return a result to callCC. We also discussed that this result should have type
Maybe (String, ContT r m ()). Thus, n has the type Int, and yield has
the type Maybe (String, Cont r m ()) -> Cont r m (). The argument type of
yield must match the return type of lexy, so the return type of lexy is
ContT r m (Maybe (String, Cont T r m ())). And that's where the daunting type
signature came from. When using this type of code in production, we would
probably introduce type aliases to make these type signatures more readable.
Back to the implementation of lexy. The first thing that lexy does is to
call callCC (\resume -> yield $ Just ("", resume ())). Thus, it yields Just
the pair ("", resume ()) back to the caller. In other words, it produces the
empty string paired with the action that can be called to make this invocation
of callCC return. When the caller invokes resume, lexy continues its
computation.
If n == 0, then the when block does not run. So lexy returns Nothing and
thereby signals to its caller that it is done producing values. That's certainly
the correct behaviour for lexy 0.
If n > 0, then lexy iterates over the characters in the alphabet "01". For
each such character x, it asks for strings of length n - 1 by calling
callCC (lexy (n - 1)). If this produces Nothing, then lexy (n - 1) is done
producing values. Thus, the current iteration of the forM_ "loop" terminates
as well, and we either start a next iteration or, if this was the last character
in "01", the when block terminates. At this point, lexy n returns
Nothing and thereby signals that it too is done producing values. If
callCC (lexy (n - 1)) produces Just a pair (xs, resume), then lexy n
yields Just the pair (x:xs, resume) to its caller. The string is the current
character x together with the string produced by lexy (n -
1). When the caller invokes resume, this directly resumes lexy (n -
1), which then yields the next string, which lexy
n augments with the current character x, and lexy n once again yields the
result back to the caller.
Let's try it all out:
>>> :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
Works like a charm.
Of course, thanks to lazy evaluation, none of the above is needed in Haskell.
The amount of effort to implement lexy in CPS is quite perverse really. The
following implementation is only slightly less efficient, but equally efficient
as the Python version of lexy, and is much shorter than even the Python
version:
lexy :: n -> [String]
lexy 0 = [""]
lexy n = "" : [x:xs | x <- "01", xs <- lexy (n - 1)]
>>> :{
| lexy 0 = [""]
| lexy n = "" : [x:xs | x <- "01", xs <- lexy (n - 1)]
| :}
>>> mapM_ putStrLn (lexy 3)
0
00
000
001
01
010
011
1
10
100
101
11
110
111
The type of this version of lexy suggests that we are producing an entire list
of strings, exactly the problem we want to avoid by implementing a coroutine
that produces these strings one at a time. However, lazy evaluation ensures that
the strings in this list are produced one at a time, just as when using a
coroutine. Each string is generated whenever mapM_ putStrLn asks for it to
print it. The coroutine that produces these strings on the fly for us is the
lazy evaluation engine built into Haskell's runtime system.