COS 441 - Continuing - Mar 7, 1996

Abort

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.

Catch and Throw

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

Exercise

Reading