CSE 341: Scheme: Continuations and exceptions

Continuations

An expression's continuation is "the computation that will receive the result of that expression". For example, in the expression

(+ 4 (+ 1 2))

the result of (+ 1 2) will be added to 4. The addition to 4 is that expression's continuation. If we wanted to represent the continuation of (+ 1 2), we might write:

(lambda (v) (+ 4 v))

That is, the continuation of (+ 1 2) takes a value, and adds four to that value.

Every expression has an implicit continuation. In most languages, continuations are hidden under the covers and not accessible at all. In Scheme, the current continuation can be reified as a function by using the built-in function call-with-current-continuation, or call/cc for short.

(call/cc expr) does the following:

  1. Captures the current continuation.
  2. Constructs a function C that takes one argument, and applies the current continuation with that argument value.
  3. Passes this function as an argument to expr --- i.e., it invokes (expr C).
  4. Returns the result of evaluating (expr C), unless expr calls C, in which case the value that is passed to C is returned.

Here is an example:

(+ 4 (call/cc
    (lambda (cont) (cont (+ 1 2)))))

This performs exactly the same computation as (+ 4 (+ 1 2)). However, it uses call/cc to capture the current continuation, and then passes the result of evaluating (+ 1 2) directly to that continuation. Another, roughly equivalent way of writing the above is as follows:

((lambda (cont) (cont (+ 1 2)))
    (lambda (v) (+ 4 v))

Continuations are first class

Continuations are first-class values, and so do not have to be called immediately:

> (define x ())
> (define (put-cont-in-x cont) (set! x cont))
> (define print-line (lambda (x) (display x) (newline)))

>(print-line (call/cc put-cont-in-x))
#<void>
> (x "hi!")
"hi"

What's going on here? The current continuation at (call/cc put-cont-in-x) is the application of print-line. By the behavior of call/cc, put-cont-in-x therefore receives a continuation that takes its argument and passes it to print-line. put-cont-in-x stores this continuation as the value bound to x. Finally, when we invoke x, the argument list gets passed to the continuation we captured --- which passes its argument to print-line, which then prints the value followed by a newline.

Continuations for "early exit" from nested evaluation

A less trivial use of call/cc follows. Consider a function that searches a list a value satisfying some predicate. The naive implementation is as follows:

(define naive-find
  (lambda (pred x)
    (cond ((null? x) ())
          ((pred (car x)) (car x))
          (else (naive-find pred (cdr x))))))

Suppose that Scheme did not perform proper tail calls. Then, this function would have to return from each recursive call when the recursion terminated. The following function fixes this problem by passing the final result directly to an earlier continuation:

(define find
  (lambda (pred aList)
    (call/cc (lambda (cont)
               (letrec ((helper (lambda (x)
                                  (cond ((null? x) (cont ()))
                                        ((pred (car x)) (cont (car x)))
                                        (else (helper pred (cdr x)))))))
                 (helper pred aList))))))

This variation of find uses call/cc to bind the continuation to cont. Then, when we reach either terminating case ((null? x) or (pred (car x))) of the recursion, we invoke cont with some value (() or x). This skips all the recursive calls, and returns the value directly to a continuation. We can prove this by executing a slightly modified version of the naive and continuation-calling find functions:

(define printing-naive-find
  (lambda (pred x)
    (cond ((null? x)      ())
          ((pred (car x)) (car x))
          (else (let ((retval (printing-naive-find pred (cdr x))))
                  (display "returning from recursive call at: ")
                  (display (car x))
                  (newline)
                  retval)))))

(define printing-find
  (lambda (pred x)
    (call/cc
      (lambda (cont)
        (letrec ((helper
                   (lambda (pred x)
                     (cond ((null? x)      (cont ()))
                           ((pred (car x)) (cont (car x)))
                           (else (let ((retval (helper pred (cdr x))))
                                    (display "returning from call at: ")
                                    (display (car x))
                                    (newline)
                                    retval))))))
          (helper pred x))))))

The printing-naive-find prints values "on the way back" from the last recursive call. The printing-find version does not, because the continuation is captured before entering the recursive function. The result is passed directly to the caller of the outermost lambda.

In the examples above, the naive-find function is tail-recursive. There's no need to use the continuation-invoking optimization to pass the end result directly to caller of the original invocation. However, for non-tail-recursive functions, the use of continuations can make returning from the "last call" a constant-time rather than linear-time operation.

The ability of call/cc to break out of deeply nested evaluation is often useful when returning errors. Consider the following function:

(define divide-or-error
  (lambda (aList divisorList errorValue)
    (cond ((null? aList) ())
          ((= (car divisorList) 0) errorValue)
          (else (let ((result (divide-or-error
                                (cdr aList) (cdr divisorList) errorValue)))
                  (if (= result errorValue)
                      errorValue
                      (cons (/ (car aList) (car divisorList))
                            result)))))))

This function divides each value in aList by the corresponding value in divisorList, and returns a list of the answers; however, if there is any zero value in divisorList, it returns errorValue instead. Again, returning from each recursive call individually is a waste of time when we only want to pass errorValue back to the original caller. By capturing the initial continuation, we can return directly:

(define divide-or-error2
  (lambda (aList divisorList errorValue)
    (call/cc (lambda (cont)
              (define helper
                (lambda (values divisors)
                  (cond ((null? values) ())
                        ((= (car divisors) 0) (cont errorValue))
                        (else (cons (/ (car values) (car divisors))
                                    (helper (cdr values) (cdr divisors)))))))
       (helper aList divisorList)))))

Notice that we no longer need to test the result of the recursive call for errorValue: when a zero is detected in divisors, it will be returned directly to the caller of the outermost lambda.

The dynamic-wind function

Sometimes, we want to make sure that a function gets executed regardless of whether a continuation "bypasses" part of the currently evaluating expression. The dynamic-wind function takes three arguments, each of which must be a no-argument function value:

(dynamic-wind inFn bodyFn outFn)

The semantics of dynamic-wind are as follows:

In the following example, open-input-file opens an input port (much like a file handle in other languages) which must be closed afterwards with close-input-port:

(define (safe-process input-file process-fn)
  (let ((p (open-input-file input-file)))
    (dynamic-wind
     (lambda () #f)
     (lambda () (process-fn p))
     (lambda () 
       (begin (display "closing input port...")
              (newline)
              (close-input-port p))))))

The in-function does nothing; the out-function closes the input port; and the body function applies the process-fn to the opened input port. Regardless of how process-fn exists, the out-function will always be called. (Typical usage of the out-function parameter to dynamic-wind closely resemble that of finally in Java.)

Now, let us apply safe-process to a function that exits prematurely:

;; Define a processing function that always exits to toplevel.
(define exit-to-toplevel 'dummy)
(call/cc (lambda (cont) (set! toplevel-exit cont)))
(define (process p) (toplevel-exit))

;; Observe that dynamic-wind still executes the code which
;; closes the input port.
(safe-process "lecture14-dynamic-wind.ss" process)
               ; displays: "closing input port..."

Exceptions

In ML and most other languages, raising and handling of exception are special constructs in the language. In Scheme, we can define exceptions as an ordinary library, using continuations and dynamic-wind.

Desired usage

It is easiest to understand how to define exceptions backwards, by going from use to definition. Here's how we'd like to use the raise function:

;; Function that raises an exception.
(define (find-or-raise pred x)
  (cond ((null? x) (raise '(empty "No such element in list.")))
        ((pred (car x)) (car x))
        (else (find-or-raise pred (cdr x)))))

Notice that we're representing the exception value as a list, where the first element of the list is a symbol naming the exception and the remaining values in the list are values to be carried with the exception.

Here's how we might like to use the handle function:

(define gt0 (lambda x (> x 0))
(define find-result
    (handle 'empty
            (lambda () (find-or-raise gt0 '(-1 -2 -3 -4)))
            (lambda (anExn) -1)))

That is, we would like the arguments to be

  1. a symbol naming the exception to be handled;
  2. a no-argument function that evaluates some body expression; and
  3. a single-argument function that takes an exception, and produces a value to be returned when that exception is raised.

(Notice that this definition of handle only adds one handler per expression. It's no fundamentally harder to have a handle that takes multiple handlers and tries them in sequence, so for simplicity we'll stick with this.)

Taken together, the uses of raise and handle above should give roughly the behavior given by the ML definitions:

exception Empty of string;
fun findOrRaise (pred, x) =
    if null x then raise Empty "No such element in list"
    else if pred (hd x) then x
    else findOrRaise pred (tl x)

val gt0 = fn x => x > 0;
val findResult =
  findOrRaise gt0 [-1, -2, -3, -4]
    handle Empty msg => -1

Now, we just need to figure out how to implement raise and handle...

The handler stack

At any given point in evaluation, there is a stack of "active" handlers. When an exception is raised, we must pop handlers off the stack until we reach one that can handle the current exception. Implementing a global stack as a list is easy enough:

(define handler-stack ())

(define (push-handler handler)
  (set! handler-stack (cons handler handler-stack)))

(define (pop-handler)
  (if (not (null? handler-stack))
      (let ((top (car handler-stack)))
        (begin
          (set! handler-stack (cdr handler-stack))
          top))))

We'll postpone the describing of the handlers themselves until we see the handler function.

Unhandled exceptions

When we get an unhandled exception, we must exit to top-level and print a message. In order to do this, we must capture a top-level continuation, which we'll invoke whenever we raise an exception with an empty handler stack:

(define exit-to-toplevel 'dummy)
(call/cc (lambda (cont) (set! exit-to-toplevel cont)))

Notice that anyone who evaluates (exit-to-toplevel) will now escape to toplevel, halting evaluation of any enclosing expression. (To prove this to yourself, write a deeply-nested expression that applies exit-to-toplevel.)

The raise function

The handler stack is a list. When an exception is raised, there are two cases:

  1. The handler stack is empty. In this case, we must print a message and exit to top-level.
  2. The handler stack is non-empty. In this case, we must test whether this handler is suitable. This has two subcases:
    1. If the handler is unsuitable, re-raise the exception.
    2. If the handler is suitable, we must evaluate the handler function and pass its result to the continuation of the handle expression.

The trickiest part of this is case 2(a). Recall our sample use of handle for the find-or-raise invocation above: when the handler for 'empty is invoked, we must return -1 to the whole handle expression's continuation (in this case, the execution of the define for find-result).

So, in order to define raise, we must have the following three things available in the handler:

We will therefore represent a handler as a three-element list (exn-name handler-cont handler-fn). Assuming that handle pushed these things onto the stack, raise is defined as follows:

(define (raise anExn)
  (if (null? handler-stack)
      (begin
        (map display '("uncaught exception: " anExn))
        (exit-to-toplevel))
      ;; else
      (let* ((exn-name (car anExn))
             (handler (pop-handler))
             (handler-exn-name (car handler)))
        (if (eq? exn-name handler-exn-name)
            (let ((handler-cont (cadr handler))
                  (handler-fn   (caddr handler)))
              (handler-cont (handler-fn anExn)))
            ;; else
            (raise anExn)))))

The handle function

Finally, we must define handle. The handle function must take three arguments: an exception name, a body function, and a handler function. It must evaluate the body function, but during the evaluation of the body we must be prepared to handle any exception raised in the handler --- i.e., we must add a handler value of the form (exn-name handler-cont handler-fn) to the handler stack.

We implement this using call/cc and dynamic-wind:

(define (handle exnName bodyFn handlerFn)
  (call/cc (lambda (cont)
             (dynamic-wind
              (lambda () (push-handler (list exnName cont handlerFn)))
              bodyFn
              (lambda () (pop-handler))))))

Notice that we use the out-function to pop the handler when bodyFn has completed execution.

Suggested exercises

  1. From the course web, download the code (lecture-15-exceptions.ss) that implements exceptions. Insert display statements at various points in the code to see when they get executed, and what the contents of handler-stack are.
  2. Draw diagrams of the heap, including the handler stack, at each of the following phases of the execution of the (define find-result ...) example above:
    1. Immediately after the call to handle.
    2. Immediately after the (call/cc (lambda (cont) ... inside handle. Represent a continuation as cell with an arrow pointing to the point in the code where the continuation's argument will be "sent".
    3. Inside the call to dynamic-wind inside handle, but before execution of bodyFn.
    4. Inside the call to bodyFn (which will be find-or-raise)
    5. Immediately inside the call to raise.
    6. After the call to (handler-fn anExn) inside raise.