5 Environmentsand Procedures
5.3 An Interpreter with let and lambda
5.3.1 Nested Environments and Recursive Evaluation
Instead of using the old \at" representation of an environment, which was just a table of name-value pairs, we'll represent nested environments as a list of tables, or environment chain.
When we begin interpretering, the environment chain will consist of one table, the top-level environment. When we evaluate a binding construct such as alet, we will create a new table, or
enviornment frame, which binds the local variables. This frame will contain the name-value pairs bound locally, plus a pointer to the next enclosing environment. The environment chain is thus a linked list that acts like a stack, for the most part|new enviornment frames are pushed on the front of the list when entering a binding construct, and popped o the front of the list when exiting it.
We could implement this stack-like behavior with an explicit stack data structure in the inter- preter, but it's easier to use the activation \stack" of the language we're using to implement the interpreter. (In this case, that happens to be Scheme, but if we were implementing the interpreter in C, we could use C's activation stack.)
At any given point during evaluation, the current environment is the environment referred to by the interpreter's variable eval, an in particular the most recent binding of eval.
When we evaluate an expression that doesn't change the interpretive environment, and calleval
recursively to evaluate subexpressions, we simply pass the envt variable's value to the recursive
calls. This will ensure that the subexpressions execute in the same environement as the containing expression.
When we evaluate a binding construct, and evaluate subexpressions in that environment, we create a new environment and pass that to the recursive calls to eval, so the subexpressions will
execute in the new enviornment instead.
Notice that we don't actually modify the environment chain when creating a new environment| we simply create a new frame which holds a pointer to the old environment, and pass it to the recursive eval. The fact that we don't actually modify the structure of the environment is
important|it's will let us implement closure correctly.
When the interpreter returns from evaluating a subexpression, it returns to an enclosing invo- cation ofeval the old environment will become visible again because we return to an evalwhere
that environment is the value of theenvtargument.
For example, consider what happens when we interpret the following expression, starting at the top level
(let ((foo 1)) (if (a) (let ((bar 2)) (if (b) (c) (d)) (e)) (f) (g))
We'll focus on the nested calls to eval corresponding to the nesting of let, if, let, if ]
If we look at the nested calls toeval, we rst see a call that evaluates the whole expression in
the top-level environment:
+---+
eval expr: (let...) envt: | *--+--> 'toplevel envt] +---+
(I've given a textual representation of the exprargument, but a pictorial representatio of the envtargument.)
eval will dispatch to eval-let, passing it the same environment. eval-let will evaluate the
initial value expression 1 in that environment, and create a new environment binding foo. (I'll
ignore the recursive call to eval to evaluate the argument.) It will then call eval recursively to
evaluate theletbody in that environment.
I'll depict the nested invocations ofevalandeval-lettop-to-bottom, showing the stack grow-
ing twoard the bottom of the picture. (This just turns out to be simpler than drawing the stack growing up.)
+---+
eval expr: (let...) envt: | *--+--> 'toplevel envt] +---+ /|\ /|\
| |
+---+ | |
eval-let expr: (let...) envt: | *--+---+ |
+---+ |
|
+---+ |
eval expr: (if...) envt: | *--+--> ' 'foo 1] * ] +---+
eval-if will evaluate the condition expression (a)in the given environment. We'll ignore that
recursive call to eval, but assume it returns a true value. In that case, eval-if will evaluate its
consequent, the inner let expression, by another recursive call toeval.
At this point, the \stack" of invocations ofeval,eval-let, andeval-if looks like this: +---+
eval expr: (let...) envt: | *--+--> 'toplevel envt] +---+ /|\ /|\
| |
+---+ | |
eval-let expr: (let...) envt: | *--+---+ |
+---+ |
|
+---+ |
eval expr: (if...) envt: | *--+---> ' 'foo 1] * ] +---+ /|\
| | +---+ | eval-if expr: (if...) envt: | *--+---+ +---+ | | +---+ | eval expr: (let...) envt: | *--+---+
Again, theletwill evaluate the intial value expression,2, by a recursive call toeval, which we
will ignore here. Then it will bind bar in a new environment frame, and call evalrecursively to
evaluate the body in that environment. The body consists of another if, soeval-ifwill be called,
and it will evaluate its argument expression and either the consequent or the alternative in that environment.
Assuming the condition returns true and it evaluates the consequent,(c), here's the \stack" of
invocations ofeval,eval-let, andeval-if at the point where (c)is evaluated:
+---+
eval expr: (let...) envt: | *--+--> 'toplevel envt] +---+ /|\ /|\
| |
+---+ | |
eval-let expr: (let...) envt: | *--+---+ |
+---+ |
|
+---+ |
eval expr: (if...) envt: | *--+---> ' 'foo 1] * ] +---+ /|\ /|\
| |
| |
+---+ | |
eval-if expr: (if...) envt: | *--+---+ |
+---+ | |
| |
+---+ | |
eval expr: (let...) envt: | *--+---+ |
+---+ |
|
+---+ |
eval expr: (if...) envt: | *--+---> ' 'bar 2] * ] +---+ /|\
| +---+ | eval expr: (c) envt: | *--+---+
Note that the pictures above all depict evaluation of nested non-tail expressions. In the case of tail expressions, the \stack" will not include as much information, because the state of the calls toeval, etc., will not be saved before the calls that evaluate subexpressions.
Our interpreter is written in good tail-recursive style, with tail calls to evaluate expressions that are tails of expressions in the language we're interpreting. This means that the intepreter is tail- recursive wherever the program it's implementing is tail-recursive, and since it's implemented in a tail-recursive language (Scheme), we preserve the tail-recurson of the program we're interpreting. In eect, we snarf tail-call optimization from the underlying Scheme system. If we were implementing our interpreter in C, we'd have to use special tricks to preserve tail recursion. We'll show how this can be done later, when we discuss our compiler. ]