HW 06

Interpreters 1-- Creating an environment

This is due on Wednesday, April 2

In this assignment you will build an interpreter and a read-eval-print loop for a small Scheme-like language, Mini-Scheme.

Section 1: Environments

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

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

Environments are extended in two ways. Let expressions have bindings that extend the environment; the body of the let is evaluated in the extended environment. Lambda expressions do not extend the environment; they simply list the variables that will be bound when the function is called. The function call is the point where the 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 associate 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 the virtues of 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 expresson

(let ([x 2]
      [y 3])
   (let ([z 4]  
          [x 5])
      (+ x (+ y z))))

will be ( ( (z x) (4 5) )    ((x y) (2 3)) )

When we resolve the bindings for x, y and z to evaluate (+ x (+ y z) ) we first find the binding 5 for x, 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, (+ x y) in an environment that first has x bound to 5, and then has the environment surrounding the defintion of f:

( (x) (5)   ( (x y) (2 3) ) )

You will see in the next two homeworks how this environment is created. At present we need to create the tools that will allow this.

Section 2: The environment datatype

It is possible to set up the environment using only Scheme lists. It is more robust to use define-datatype to work with Scheme datatypes. Recall that we could create a structure holding a list of atoms, which I'll call an "alis," with

(require (lib "eopl.ss" "eopl"))

(define-datatype alis a-list?
          (first atom?)
          (rest a-list?)))

We could build up structures with definitions such as

(define zip (empty-alis))
(define a (nonempty-alis 1 zip))
(define b (nonempty-alis 2 a))

Finally, we could build some helper functions to take apart an alis into its component parts:

(define head (lambda (l)
       (cases alis l
              (empty-alis () 'error)
              (nonempty-alis (first rest) first))))

(define tail (lambda (l)
       (cases alis l
              (empty-alis () 'error)
              (nonempty-alis (first rest) rest))))

So, for example (head b) is 2 and (head (tail b)) is 1.

In a similar way we can define a datatype for our environments. An environment is either empty or else it is a triple consisting of a list of symbols, a list of values, and the previous environment:

(require (lib "eopl.ss" "eopl"))

(define-datatype environment environment?
              (syms (list-of symbol?))
              (vals (list-of anything?))
              (env environment?)))

(define anything? (lambda (v) #t))

We can define a unique empty environment (whick lets us check if we are in the empty environment:

(define the-empty-env (empty-env))

We build up new environments with procedure extended-env. For example

(define EnvA (extended-env '(x y) '(1 2) the-empty-env))
(define EnvB (extended-env '(x z) '(5 7) EnvA))

All that remains is to build some helper functions to look up bindings in an environment.

Section 3: apply-env


Create a Scheme file env.ss that contains the define-datatype definition of the environment datatype. Write function apply-env, 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 above

(apply-env EnvA 'x) should return 1
(apply-env EnvB 'x) should return 5, and
(apply-env EnvB 'y) should return 2

If apply-env does not find a binding for the symbol you should invoke the error handler:

    (error 'apply-env "No binding for ~s" sym)

Section 4: The initial environment


Add to your env.ss file definitions of the-empty-env and a new environment called init-env that contains bindings for variables x and y to numbers (such as 1 and 2); we will use this initial environment to test out features of MiniScheme before we implement expressions that allow us to extend the environment with new bindings.