CSCI 275
This is the first portion
of your Scheme interpreter. The initial portion of this lab is material we
discussed in class on Tuesday, March 3. If you followed this you can skip
down to the 3 exercises at the end of this document.
We
need to create an environment to hold the data for the expressions we will
interpret. The scoping rules for Scheme determine the structure of this
environment. Consider the following three examples. First
(let ([x 2] [y 3])
(+ x y))
This
has an environment with bindings for x and y created in a let-block. These
bindings are used in the body of the let.
Next,
consider the following. This has a let-block that creates a lamba-expression,
which is called in the body of the let:
(let ([f (lambda (x) (+ x 4))])
(f 5))
When
this is evaluated we want to bind x to the value of the argument, 5, and then
evaluate the body of f using that binding.
Finally, we combine these. At the outer level in the following expression we
have a let-block with bindings for x and y. The body is another, nested, let,
which binds a lembda expression with a parameter x. The body of the interior
let is a call to the function.
(let ([x 2] [y 3])
(let ( [f
(lambda(x) (+ x y))])
(f 5)))
When
we evaluate this we first make an environment with bindings of x and y to 2 and
3 respectively, then we use this to evaluate the inner let-expression. In that expression we make a binding of f to
the value of the lambda expression (a closure, of course), and then we call f
with argument 5. This requires us to
evaluate the body of f with x bound to 5.
The body of f does not have a binding for y, so we look it up in the
outer environment and see that its value is 3.
Finally, we evaluates (+ x y) with x bound to 5 and y bound to 3,
yielding 8 for the value of the full expression.
Environments
are extended in two ways. Let-expressions have bindings that extend the current
environment; the body of the let is evaluated in the extended environment.
Lambda expressions do not extend the environment; they evaluate to closures
that store the environment in place when the lambda is evaluated. The function call is the point where the closure's environment is
extended, with the parameters from the lambda expression being bound to the
values of the arguments.
We
will define the environment as an association list, where symbols are
associated with values. There are two ways we might do this. In the first
example above, where x is bound to 2 and y to 3, we might use the list (
(x 2) (y 3) ), or we might use ( (x y) ( 2 3) ). The
former structure is closer to the way the bindings appear in let-expressions;
the latter is closer to the components of a call. The former structure might
appear simpler, but thanks to procedure map,
the latter is actually easier to code and we will go with that.
Scheme,
and most other languages you are likely to use, employs lexical scoping. When
we want to resolve the binding for a free variable, we look first in the
current scope, then in the surrounding scope, then in the scope that surrounds
that, until the variable is found or we reach the outermost scope. To implement
this our environments will be structured as linked lists, with the head of the
list the current scope and the tail the previous environment. Thus, the
environment for the expression
(let ([x 2] [y 3])
(let ([z
4] [x 5])
(+ x (+ y z))))
will
be something like ( ‘env (z x)
(4 5) ( ‘env (x y) (2 3) (‘empty-env)))
When
we resolve the bindings for x, y and z to evaluate (+ x (+ y z) ) we
find the binding 5 for x (there are two bindings for x, but the one we want is
closer to the head of the list so it is the first one we come to), and of
course we find 4 for z and 3 for y. This leads to the correct value, 12 for the
expression.
Similarly,
in the expression
(let ([x 2] [y 3])
(let ([f (lambda(x)
(+ x y))])
(f 5)))
we
evaluate the call (f 5) by evaluating the body of f in an environment that
first has x bound to 5, and then has the environment surrounding the definition
of f.
You
will see in the next two labs how this environment is created. At present we
need to create the tools that will allow this.
Part 2: The environment datatype
The
two most important features of an environment are that we need to be able to
look up symbols to get the values they are bound to, and we need to be able to
extend an environment with new bindings to get a new environment. We'll define
an environment as either the empty environment, with no bindings, or an extended
environment with a list of symbols, a
corresponding list of the values those symbols are bound to, and a previous
environment that is being extended. Here are constructor functions for the two
types of environment:
(define empty-env (lambda () (list 'empty-env)))
(define extended-env (lambda (syms vals old-env)
(list 'extended-env syms vals old-env)))
Note
that extended-env is the function you will use every time you need to extend an
environment when evaluating a let-expression or a function call. For example,
when evaluating the expression (let ([x 1]
[y 2]) …) we might use
(define EnvA (extended-env '(x y) '(1 2)
the-empty-env))
We could further extend this environment:
(define EnvB (extended-env '(x z) '(5 7)
EnvA))
This
datatype has easy recognizer functions:
(define empty-env? (lambda (x)
(cond
[(not
(pair? x)) #f]
[else
(eq? (car x) 'empty-env)])))
(define extended-env? (lambda (x)
(cond
[(not
(pair? x)) #f]
[else
(eq? (car x) 'extended-env)])))
(define environment? (lambda (x)
(or (empty-env? x)
(extended-env? x))))
The
accessor functions for the different fields of an extended environment are also
easy:
(define syms (lambda (env)
(cond
[(extended-env?
env) (cadr env)]
[else (error
'syms "bad environment")])))
(define vals (lambda (env)
(cond
[(extended-env?
env) (caddr env)]
[else
(error 'vals "bad environment")])))
(define old-env (lambda (env)
(cond
[(extended-env?
env) (cadddr env)]
[else (error
'old-env "bad environment")])))
We
can define a unique empty environment::
(define the-empty-env (empty-env))
All
that remains is to build some helper functions to look up bindings in an
environment.
Part 3: Exercises
Exercise 1: lookup
File env.rkt contains the code
given above for the Environment datatype. Add
to this file function (lookup environment symbol), which takes an
environment and a symbol and returns the first binding for that symbol in the
environment. For example, with environments EnvA and EnvB defined as
(define EnvA (extended-env '(x y) '(1 2)
the-empty-env))
(define EnvB (extended-env '(x z) '(5 7) EnvA))
we should have the following behavior:
(lookup EnvA 'x) should return 1
(lookup EnvB 'x) should return 5
(lookup
EnvB 'y) should return 2
(lookup EnvB 'bob) should cause an error
If lookup-env does not find a binding for the symbol you should
invoke the error handler (error
sym string ), as in
(error 'apply-env "No binding for
~s" sym)
Exercise 2: init-env
Add to your env.rkt file the definition of a new environment
called init-env that
contains bindings for variables x and y to numbers 10 and 23; we will use this initial
environment to test out features of the MiniScheme interpreter before we
implement expressions that allow us to extend the environment with new
bindings.
Exercise 3: provide and testing
Make
your env.rkt file into a module by givng provide directives for all
of the objects you want to make availablel to other modules. For example,
(provide environment? empty-env? extended-env? ...init-env)
You
can supply any number of these at the top of the program; I find it convenient
to group the functions being exported this way into related clusters.
To
test your module, open a new Scheme file, have it require env.rkt with
(require "env.rkt")
and
then run
(lookup init-env 'x)
This should give the value 10 you bound to x in the initial environment.
For
this lab you only need to hand in the env.rkt file.