In Algol-like languages like Pascal and Algol W, all of the environments that exist at any point during a computation can be collectively represented using a stack. This representation for evironments is particularly advantageous because the enviroment stack can be implemented as an elaboration of the control stack embedded in the architecture of a modern computer to support procedure calls.
Programs written in "advanced" languages like Scheme, ML, Jam, and LC can be restricted to accommodate this implementation method by prohibiting program-defined functions from appearing anywhere except as the right hand side of definitions and as arguments in procedure calls. Most Scheme/ML programs make only occasional use of procedures as data (closures), so some implementations (like Chez Scheme and DrScheme) still rely on a central stack and heap allocate variables that appear free in lambda expressions.
Almost all modern computers use a stack to store the return addresses of procedure calls. In addition, other context information (such as the contents of registers that may be modified by the called the procedure) is typically saved with the address address on this "control" stack. To return, the called procedure pops the pushed information off the stack, restores the saved context information (typically the contents of registers) and jumps to the specified return address. The "popping" of the stack removes the top (most recent) "frame" from the stack, restoring the stack to the form it had before the call.
Some machines also pass argument values to procedures in the stack. The argument values are pushed on the stack along with the return address and the saved context information. Another common convention is to pass arguments (assuming the number is small) in registers. The result returned by a procedure is typically returned in a register because the stack frame associated with the call is deallocated on return.
In the absence of tail-call optimization which converts procedure calls to jumps, every procedure call allocates a new stack frame. Hence a recursive procedure may allocate a large number of stack frames. The standard definition of the factorial function, for example, allocates 1001 frames to evaluate 1000!
In the course of an computation, an exceptional condition may be encountered that requires abandoning a subcomputation. If that subcomputation has a large number of associated stack frames, the "bubbling" action require to pass a special value back up the call chain is time-consuming and awkward to program. What we want is a construct that lets us label a selected stack frame as a recovery point and return a value directly to that frame (just as if the next stack frame had returned normally). The "direct return" operation deallocates all of the stack frames from the current frame back to the recovery frame; this can be done simply by changing the contents of the register serving as the stack pointer. In addition, it places the return value in the standard place (usually a register) in determined by the procedure calling conventions.
letcc
Construct letcc
:
(letcc Xit (+ (Xit 15) 5)) = 15
(letcc Xit (lambda (x) (+ (Xit (lambda (x) 5)) x)))
cannot be evaluated any further; (lambda ...)
is a
value, and Xit
occurs free in it
((letcc Xit (lambda (x) (+ (Xit (lambda (x) 5)) x))) 25)
= (letcc Xit2 ([lambda (x) (+ ((lambda (v) (Xit2 [v 25])) (lambda (x) 5)) x)] 25))
= (letcc Xit2 (+ ((lambda (v) (Xit2 [v 25])) (lambda (x) 5)) 25)
= (letcc Xit2 (+ (Xit2 [(lambda (x) 5) 25]) 25)
= (letcc Xit2 (+ (Xit2 5) 25)
= 5
letcc
expression is evaluated, it
turns its current context (as in, ``complete textual context'') into a
procedural object. This procedural object is also known as a
continuation object. When a continuation object is applied,
it forces the evaluator to remove the current evaluation context and
to re-create the context of the original letcc
expression, filled with the value of its argument. Like procedures,
continuation objects are first-class values, which means they can be
stored in data structures or tested by predicates.
Now we understand the semantics of letcc
, but how do we
use it to write programs? We need to also understand its pragmatics.
For instance, letcc
can be used for exception
handling.
Example:
Let us write a procedure, PiHelp
, that computes the product
of a list of digits. The data definition looks like this:
lod ::= null | (cons [0-9] lod)Our procedure might look like this:
(define PiHelp (lambda (l) (cond ((null? l) 1) (else (* (car l) (PiHelp (cdr l)))))))However, suppose it is possible that we can get an invalid digit (in the range
[a-f]
); if we do, we want the result of
invoking PiHelp
to be false. We can add the following clause
within the cond
statement,
((bad? (car l)) (Xit #f))where
Xit
is some unused identifier.
We use the following recipe for constructing such programs:
(Xit V)
(where V
represents the value to be returned in an
exceptional situation) where Xit
is some new
identifier.
Xit
by making it a parameter in the argument
list to the procedure.
Xit
, we simply pass it along
at all these call sites.
(define Pi (lambda (l) (letcc Abort (PiHelp l Abort))))
If we pass this new procedure exceptional data, we get
(Pi '(1 2 b)) ==> (letcc Abort (PiHelp '(1 2 b) XXX)) ==> (letcc Abort (* 1 (*2 (PiHelp #f)))) ==> #fas desired.
Exercises:
PiHelp
, could
we have written (define Pi (letcc Xit ...))
?
(define Pi
(lambda ... (letcc Xit ...)))
instead?
PiHelp
inside that of
Pi
using local
?
There are numerous control constructs that we can add to LC. Some of these are:
(raise M)
stops computation and returns the value of
M
. (raise M)
corresponds to an
exceptional datum condition for the meta-evaluator. Hence, it can
be added to the evaluator by following the steps above.
raise
by delimiting the extent to which it can
escape. Such a construct is called an abort delimiter,
and is sometimes written as prompt
or #
.
Suppose in Scheme we wrote
(lambda (f G) (open-file f) (G f) (close-file f))If
G
executes an raise
statement, then
the file will never be closed. This might be undesirable. To
prevent this, we can instead write
(lambda (f G) (open-file f) (# (G f)) (close-file f))
#
can be added to the evaluator with the following
code:
((#? M) (letcc NewXit (MEval (#-body M) env NewXit)))This is a non-orthogonal change to the interpreter, but it is orthogonal with respect to the language, since the functional core remains the same.
(# M
H)
where H
is invoked only if M
aborts. (The code in H
might typically be used to
perform some clean-up action.) Additional extensions are
possible: we could have labeled exceptions, and we could also have
restartable exceptions (where raise
returns a value
and the continuation active at the time it was invoked).
Here is the core of an interpreter that implements #
and
raise
. This version of #
takes a body and a
handler, as outlined above. The handler takes one argument, which is
the value ``thrown'' by raise
.
(define MEval/ec (lambda (M env Exit) (cond ... ((raise? M) (Exit (MEval/ec (raise-expr M) env Exit))) ((#? M) ((letcc new-Exit (lambda () (MEval/ec (#-body M) env (lambda (raised-value) (new-Exit (lambda () (MApply/ec (MEval/ec (#-handler M) env Exit) raised-value Exit))))))))))))(Note that all calls to the former
MEval
will now have to
call MEval/ec
instead, passing on the Exit
handler unchanged; only #
installs new handlers.
MApply/ec
is similarly modified.)
At this point, we will conclude our study of meta-interpreters. We have thusfar covered the following:
Shriram Krishnamurthi / shriram@cs.rice.edu