SML code examples for this lecture
Continuations are a way of thinking about program control flow in terms of explicitly passing around objects that represent computation to be done. Unlike function objects, which also represent computation to be done, continuations represent dynamic or run-time computations. We have already seen this kind of programming construct with exceptions, where raising an exception causes a current computation to be terminated, and another computation to be started, passing some values from the current computation to the new one. The new computation is not determined lexically (by the surrounding program text), but rather is determined by the run-time path of execution of the computation. That is, we do not know what handler, if any, will handle an exception that has been raised simply by looking at the program text. We need to simulate the execution of the program to see if there are is dynamically enclosing context that handles the raised exception. This non-local sort of exit is very powerful, but also can be a bit hard to reason about. Continuations are an even richer way of representing such non-local control flow, by explicitly passing around an argument that represents a computation. One can use continuations both to implement exceptions, and also to implement threads.
Continuations are currently primarily of theoretical interest, unlike exceptions and threads which are present in most high-level languages and are widely used in writing large systems.
One can think of a continuation as something that is waiting for a value, in
order to perform calculations with it. For instance, if we look at a
simple arithmetic expression such as (* (+ 1 2) (+ 3 4))
, when
(+ 1 2)
returns the value 3 there is a computation waiting for that
result, namely * of that and the result of (+ 3 4))
. At any
time in the execution of a sequential program there is a current continuation,
namely the rest of the program that remains to be executed. For instance,
after adding 1 and 2 above, the current continuation is to multiply that value
by the result of adding 3 and 4. One can think of continuations as the "missing
part" of the environment model. The environment model specifies only how
to look up bindings of names, and not control flow. The current
continuation is the remaining computation as each expression is evaluated in the
environment model.
SML has a continuation structure called Cont
in the SMLofNJ
module. We will consider just two of the operations in that structure,
callcc
and throw
.
signature CONT = sig type 'a cont (* callcc f applies function f to the current continuation. * If f invokes this continuation with argument x it is as * if(callcc f) had returned x as a result *)
valcallcc: ('a cont -> 'a) -> 'a (* Invoke continuation k with argument a *)
valthrow: 'a cont -> 'a -> 'b
... end
Let's look at a simple example of using continuations:
open SMLofNJ.Cont;
3 + callcc (fn k => 2 + 1);
3 + callcc (fn k => 2 + throw k 1);
The first of these expressions evaluates to 6, because the computation 2+1 is done, that value is returned by callcc and 3 is added to the result.
The second of these expressions evaluate to 4, because the computation 2 +
"something" is done, but that computation rather than returning a value invokes
the continuation k with the value 1. 3 is then added to that value (1).
This is a "nonlocal exit" similar to raising an exception, but there is no
exception type defined and no handler. The "return" is to the specified
continuation, which in this case is the rest of the computation after 3+ (the
current continuation at the time of callcc
).
Let's look at a slightly more involved example, but similar in form.
open SMLofNJ.Cont;
fun multiply1 l =
callcc (fn ret =>
let
fun mult [] = 1
| mult (0::_) = throw ret 0
| mult (n::l') = n * mult l'
in
mult l
end);
fun multiply2 l =
callcc (fn ret =>
let
fun mult [] = 1
| mult (0::_) = 0
| mult (n::l') = n * mult l'
in
mult l
end);
fun multiply3 l =
let
fun mult [] = 1
| mult (0::_) = 0
| mult (n::l') = n * mult l'
in
mult l
end
multiply1 [1,2,3,4,5];
multiply1 [1,2,3,4,0,5];
multiply2 [1,2,3,4,5];
multiply2 [1,2,3,4,0,5];
multiply3 [1,2,3,4,5];
multiply3 [1,2,3,4,0,5];
On the list [1,2,3,4,5]
each of these functions returns 120 and
on the list [1,2,3,4,0,5]
each function returns 0. However
there is a critical difference in how multiply1
works compared with
the other two. When multiply1
encounters a 0 value, the
current pending computation to multiply all the earlier element in the list is
abandoned without doing that work, whereas in the other two functions a
multiplication by 0 is done for each previous element in the list in order to
eventually return 0. Continuations provide us with a way of jumping to
some other computation.
Continuations can be used in place of raising exceptions, as in the following example:
open SMLofNJ.Cont; let fun g(n: real) (errors: int option cont) : int option = if n < 0.0 then throw errors NONE else SOME(Real.trunc(Math.sqrt(n))) fun f (x:int) (y:int) (errors: int option cont): int option = if y = 0 then throw errors NONE else SOME(x div y+valOf(g 10.0 errors)) in case callcc(f 13 3) of NONE => "runtime error" | SOME(z) => "Answer is "^Int.toString(z) end
Now lets look at a more involved use of continuations, to implement a simple threads package (adapted from A. Appel and J. Reppy - Reppy is the designer of CML). Unlike real threads, which are preemptively scheduled so that they can be interrupted at any time in the computation, we will have a simple thread package that requires threads to yield to other threads. However, like real threads it is still unclear what order things will run in, as threads yield after unknown time periods thereby queuing their "remaining work" for later processing.
We will use a simple mutable queue implementation with the following signature:
signature QUEUE = sig type 'a queue exception Dequeue val new : unit -> 'a queue val enqueue : 'a queue * 'a -> unit val dequeue : 'a queue -> 'a (* raises Dequeue *) val clear : 'a queue -> unit end
The signature for our simple threads package will be:
signature THREADS =
sig
exception NoRunnableThreads
val spawn : (unit -> unit) -> unit
val yield : unit -> unit
val exit : unit -> 'a
end;
We can implement a thread as a continuation, keeping a queue of all the threads that have been spawned and are not currently running.
open SMLofNJ.Cont;
structure T :> THREADS =
struct
exception NoRunnableThreads
type thread = unit cont
val readyQueue : thread Q.queue = Q.new ()
fun dispatch () =
let
val t = Q.dequeue readyQueue
handle Q.Dequeue => raise NoRunnableThreads
in
throw t ()
end
fun enq t = Q.enqueue (readyQueue, t)
fun exit () = dispatch ()
fun spawn f = callcc (fn parent => (enq parent; f (); exit ()))
fun yield () = callcc (fn parent => (enq parent; dispatch ()))
end;
Here is a simple example of using this threads package:
fun prog1() =
let
val counter = ref 100
fun spew(s) = if !counter <= 0 then T.exit()
else (TextIO.print(s);
counter := !counter-1;
T.yield();
spew(s);
())
in
(T.spawn(fn () => spew("hello!\n"));
T.spawn(fn () => spew("goodbye!\n"));
TextIO.print "MAIN THREAD DONE\n";
T.exit())
end
handle T.NoRunnableThreads => TextIO.print "\nDone\n";
Here is a slightly more involved example of using the threads package for a pair of producer and consumer threads:
val buffer : int Q.queue = Q.new ();
val done : bool ref = ref false;
fun deq () = SOME (Q.dequeue buffer) handle Q.Dequeue => NONE;
fun enq (n) = Q.enqueue (buffer, n);
fun producer (n, max) =
if n > max then (done := true; T.exit ())
else (enq n; T.yield (); producer (n+1, max));
fun consumer () =
(case deq()
of NONE => if !done then T.exit()
else (T.yield (); consumer ())
| SOME(n) => (TextIO.print (Int.toString n); TextIO.print " ";
T.yield (); consumer ()));
fun run () =
(Q.clear(buffer); done := false;
T.spawn (consumer); producer (0,20); ())
handle T.NoRunnableThreads => TextIO.print "\nDone\n";
run();