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))