Suppose we add a new procedure to Scheme called abort which
forgets the current evaluation context and exits.
This procedure has the following reduction rule.
D*E[(abort e)] -> D*(abort e)
If we had such functionality we could easily write a version
of Pi
that avoids any multiplies when there is a zero in the list:
(define Pi
(lambda (l)
(cond ((null? l) 1)
((zero? (car l)) (abort 0))
(else (* (car l) (Pi (cdr l)))))))
(Pi '(1 2 0 3 4))
We can add this to our meta-circular interpreter in the
following way, provided we have abort in the
meta-language (the language we're using to implement the
interpreter):
(Abort (body) (abort (eval body env)))
Unfortunately, Scheme has no such procedure. Recall that CPS helped us
solve this problem with Pi in the first place. Perhaps it
can help here as well. Applying the CPS transformation to our
meta-circular interpreter gives:
(define eval
(lambda (e env k)
(variant-case e
(Const (value)
(k value))
(Var (name)
(k (lookup env name)))
(Ap (fun arg)
(eval fun env
(lambda (vfun)
(eval arg env
(lambda (varg)
(vfun varg k))))))
(Lam (name body)
(k (lambda (arg k2) (eval body (extend env name arg) k2))))
Now let's try to add abort. We evaluate the body
in the current environment, but discard the current continuation.
Hence we just need to feed the body a new initial continuation:
(Abort (body) (eval body env (lambda (x) x))))))
Now a list with a zero passed to Pi will do no multiplications
using this new eval.
But what happens if we try to evaluate the following.
(+ 3 (Pi '(1 2 0 3 4)))We get zero because
abort jumped out of the
(+ 3 []) context as well.
The abort operator is called a
non-local control operator, or simply a control operator,
because it alters the flow
of a program's execution in a non-local manner. (In contrast,
if is a local control operator.) We can
build a more flexible control operator by labeling the place where
we want to jump to.
The new control operators are catch and
throw. The operator catch labels the place
to jump to, and throw jumps to the nearest
catch with the same name. To define a reduction
semantics for catch and throw, we need to
define two different kinds of evaluation contexts:
E ::= [] | (v* E e*) | (throw x E) | (catch x E)
F ::= [] | (v* F e*) | (throw x F)
The F contexts are a subset of the E
contexts; F contexts do not let us look inside
a catch.
Now the reduction rules are:
D*E[(catch x v)] -> D*E[v] (x not in FV(v))
D*E[(catch x F[(throw x v)])] -> D*E[v] (x not in FV(v))
D*E[(catch x F[(throw y v)])] -> D*E[(throw y v)] (x != y) (x not in FV(v))
The first rule says that if we evaluate the body of a
catch to a value v, we just discard the
catch and return v.
The second rule jumps to the closest enclosing catch
by discarding
F. But note that F does not include
any catch, so the closest enclosing catch
might have a different name from the throw. The
third rule handles this by jumping there anyway (discarding
F), and then continuing to jump further (by placing
the throw expression in E.
Now let's implement catch and throw
in our interpreter. catch builds a Cont
object to serve
as the label. This object consists of the current continuation.
throw evaluates the body to a value
v, looks up its name to get a Cont object,
and passes v to the continuation from the
Cont object. Note that throw
ignores the current continuation k.
(Catch (name body) (eval body (extend env name (make-Cont k)) k))
(Throw (name body) (eval body env
(lambda (v)
(variant-case (lookup env body)
(Cont (k) (k v))))))
There is no reason to construct a Cont record.
Let's take it out:
(Catch (name body) (eval body (extend env name) k))
(Throw (name body) (eval body env (lambda (v) ((lookup name env) v))))
Now throw looks up its name just like any other variable use.
Suppose we make that position an evaluated expression.
(Throw (name body)
(eval name env
(lambda (k2)
(eval body env
(lambda (v) (k2 v))))))
If we replace k2 with vfun and v with
varg this looks a lot like regular application. All that
is different is the number of arguments passed to k2
aka vfun.
Suppose we add an extra ignored argument to the procedure that
catch binds to its name, and pass k
in the code for Throw:
(Catch (name body)
(eval body (extend env name (lambda (v k2) (k v))) k))
(Throw (name body)
(eval name env (lambda (x) (eval body env (lambda (v) (x v k))))))
Now we no longer need throw because it behaves just like
application. So we can take throw out of the language
and just use ordinary procedure application.
Here's an example:
(define Pi
(lambda (l)
(catch stop
(cond ((null? l) 1)
((zero? (car l)) (stop 0))
(else (* (car l) (Pi (cdr l))))))))
(+ 3 (Pi '(1 2 0 3 4)))
This program gives 3, as we would expect.
The Seasoned Schemer calls catch letcc.
Let's write a reduction rule for letcc.
D*[(letcc x e)] -> D*E[(let ((x (lambda (y) (abort E[y])))) e)]
or
D*[(letcc x e)] -> D*E[e[x |-> (lambda (y) (abort E[y]))]]
We need to put abort in the procedure
(lambda (y) ...)
representing the continuation because that procedure would
continue evaluating after it had finished running E.
For example in
(+ 1 Pi '(1 2 3))The procedure bound by the
letcc is
(lambda (y) (abort (+ 1 y)))Without the
abort, when applied within Pi it would
not jump out. The continuations captured by letcc are
abortive: they include what's left to do in the entire program, and no
more. We use abort in reduction semantics to express the reduction
rules for letcc. Scheme does not provide abort.
MIT Scheme does not have letcc. It gives us
call-with-current-continuation. We will refer to it as
call/cc for short. It is related to letcc as follows
call/cc = (lambda (f) (letcc k (f k)))
(letcc x e) = (call/cc (lambda (x) e))