Fall 99, CSE 520: Lectures 16 and 17


Interpreting PCF in ML

An interpreter is a program that implements the evaluation relation of a language. Thus, writing an interpreter for PCF means defining, in a real programming language (interpretation langauge), the relation eval that we have specified as the operational semantics of PCF.

Remember that eval is a relation between terms. More precisely, eval relates a term to its canonical form (which is also a term). It can be proved that eval is deterministic, namely if M eval N and M eval P hold, then N = P. For this reason, eval is actually a function. And of course, its type is term -> term.

We will see that it is really easy to build an interpreter following the rules of the operational semantics. We will use ML as the interpretation language, because it is particularly suited for symbolic computation. ML indeed stands for "Meta Language", i.e. a language meant to describe and manipulate other langauges.

In these notes we will see how to build an ML interpreter for the eager PCF. The lazy version will tbe topic of an assignmenmt.

Let us recall the definition of the syntax of PCF. For simplicity, we will not consider pairs.

   Term ::= Var| \Var.Term | Term Term        % lambda terms 
          | Num | true | false                % numerical and boolean constants
          | Term Op Term                      % numerical ops and comparison 
          | if Term then Term else Term       % conditional
          | let Var = Term in Term            % term with a local declaration 
          | fix Term                          % the fixpoint operator 

A version of the operational semantics more suited for implementation

We could define an interpreter based directly on the operational semantics defined in Lectures 13 and 15. However, the notion of substitution, which occurs in the rules for application and fixpoint, is quite complicated to implement. In fact, we would need to make syntactical replacements in the term we want to evaluate, which is an expensive operation, We also would need to rename variables in case of conflict (so to avoid capture), and this would require generating new syntactic entities, which is a bit cumbersome.

The typical interpretation technique for avoiding substitution is the use of environments. An environment is a function which associates variables to terms (or a list of associations variables-terms). Instead of making a substitution of, say, M for x, we enrich the environment with the association between x and M. Then, whenever we will need to evaluate a term containing x, we will use the environment to obtain the value of x.

The evaluation relation will have to be modified so to take into account the dependency on the environment. We will write:

r |- M eval N
to mean that M evaluates to N in the environment r.

A naive solution

We first see a solution which derives immediately from the substitution-based semantics by using the environment in a naive way. This solution is "almost correct", if we assume that the bounded variable are all distinguished. We will see later that multiple bindings of the same variable name introduce some complications, and we will see how to cope with them.

The rules of the eager semantics are the following:

The above semantics looks similar to the substitution-based semantics, but, in presence of multiple declarations of the same variable, the two definitions are not equivalent. Additionally, there is a problem with the fixpoint rule. We will see in the next section how these problems arise, and how to solve them.

The problem of multiple declarations of the same variable

When we have multiple declarations of the same variable, the above environment-based semantics is incorrect wrt the substitution-based semantics (and hence incorrect wrt to the theory of the lambda calculus).

One problem is related to the issue of lexical scope (or static scope). The above semantics, in fact, reflects dynamic scope, while a correct treatment of the the substitution-based semantics requires static scope.

As an example, consider the following term:

   let x = 1 
    in let f = \y. y+x
        in let x = 2
            in f 5
With the substitution-based semantics the value of this term is 6: the binding for x, in the definition of f, is the one valid at declaration time (static scope). With the environment-based semantics described above, on the contrary, the result is 7. This is because the binding for x, in f, is the one valid at execution time, i.e. when f is used (dynamic scope).

Another problem is illustrated by the following example:

   (\f. let x = 2 in f 5) (let x = 1 in \y. x + y)
The result of this term clearly should be 6. The above semantics, instead, gives 7.

Note that analogous examples could be given in the pure lambda calculus. In fact, let x = M in N is equivalent (from the operational point of view, in the eager semantics) to (\x.N)M.

Finally there is another reason why the the above semantics is incorrect, and has to do with the fixpoint rule. The problem arises for instance in the evaluation of an expression of the form (fix M) 5, where (fix M) is a term representing, say, the factorial function. When evaluating (fix M), the above fixpoint rule will give as result the abstraction obtained by unfolding the definition of factorial one time, but we lose the link between the fixpoint variable (the first parameter of M) and the term (fix M).

The standard way of solving these problems is by introducing the notion of closure. Basically, a closure is a term together with the environment in which it has to be evaluated. We will represent closures as pairs (term, environment). Closures are used at the moment of application: when a function application needs to be evaluated, its body must be evaluated in the environment of the closure.

The rules that need to be modified are the following:

Furthermore, we need to add the following rule which specifies how to evaluate closures.

The ML interpreter for eager PCF

We need first of all to represent terms and environments in ML. For terms, we can use a datatype declaration. This will allow us to represent directly their structure, i.e. their parse tree (or abstract syntax) and will, of course, make things much easier in defining the function eval. In fact, the evaluation relation of the operational semantics is defined by structural induction. Note that, if we would start with a flat representation of terms as strings, then we would have first to reconstruct their structure (parsing).

In the definition of the datatype of terms, we have a case for each production of the grammar, plus a case of "error" to represent the result of an evaluation when something goes wrong. We will assume the following PCF operations on numbers: plus, times, minus, division, and test of equality.

   datatype term = var of string           	  (* variables *)
                 | abs of string * term    	  (* abstractions *)
                 | app of term * term      	  (* applications *)
                 | num of int              	  (* numbers *)
                 | tt | ff                 	  (* booleans *)
                 | plus of term * term     	  (* aritmetical operation *)
                 | minus of term * term    	  (* aritmetical operation *)
                 | times of term * term    	  (* aritmetical operation *)
                 | divis of term * term    	  (* aritmetical operation *)
                 | equal of term * term           (* comparison operation *)
                 | ite of term * term * term      (* conditional *)
                 | letvar of string * term * term (* local declaration *)
                 | fix of term   	    	  (* fixpoint *)
                 | closure of term * (string -> term) (* closure *)
                 | error;                         (* erroneous situation *)

For the environments, we could use lists of pairs (associations) variable-term. However, a more elegant solution is to use functions from variables to terms. This is made possible by the higher-order capabilities of ML.

   type environment = string -> term;
We also need to define the empty environment. This will simply be the function which associates an undefined value (error) to any variable. In fact, the empty environment represents the state of the environment before any declaration or passing of actual parameters. In this situation, all variables are undefined, and the attempt of evaluating a variable should give an error.
   val emptyenv:environment = fn x => error;
Finally, we need to define a function to update an environment with a new association variable-term:
   fun update (r:environment) (x:string) (M:term) = (fn y => if y = x then M else r y):environment; 
We can now define the interpreter (function eval). The definition follows very closely the rules for the operational semantics. Although very simple, the interpret is complete. The only thing that is missing is a better (interactive and user-friendly) interface. In particular the treatment of the error cases could be much more sophisticated. As it is now, certain erroneous suituations are not treated by the program: it will give a run-time error and abort. This problem is partly reflected by the message "Warning: match nonexhaustive" that we get at compile-time. The enrichment of the interpreter with error treatment is left as a (useful) exercise.
   fun eval (var x) r 	   = eval (r x) r
     | eval (abs(x,M)) r   = closure(abs(x,M),r)
     | eval (app(M,N)) r   = let val closure(abs(x,M1),r1) = eval M r
                             in eval M1 (update r1 x (eval N r))
                             end
     | eval (num n) r      = (num n)
     | eval tt r           = tt
     | eval ff r           = ff
     | eval (plus(M,N)) r  = let val (num m) = (eval M r) and (num n) = (eval N r)
                             in num (m + n)
                             end
     | eval (minus(M,N)) r = let val (num m) = (eval M r) and (num n) = (eval N r)
                             in num (m - n)
                             end
     | eval (times(M,N)) r = let val (num m) = (eval M r) and (num n) = (eval N r)
                             in num (m * n)
                             end
     | eval (divis(M,N)) r = let val (num m) = (eval M r) and (num n) = (eval N r)
                             in num (m div n)
                             end
     | eval (equal(M,N)) r = let val (num m) = (eval M r) and (num n) = (eval N r)
                             in if n = m then tt else ff
                             end
     | eval (ite(M,N,P)) r = (case (eval M r) of tt => eval N r | ff => eval P r)
     | eval (letvar(x,M,N)) r  = eval N (update r x (eval M r ))
     | eval (fix M) r      = let val closure(abs(x,M1),r1) = eval M r
                             in eval M1 (update r1 x (closure(fix M,r)))
                             end
     | eval (closure(M,r1)) r  = eval M r1
     | eval error r        = error;

Example

The following example illustrates the evaluation of a term which consists of a declaration of "fact" as the factorial function, and the application of "fact" to the number 5. The result of the evaluation will be (num 120)
                
   eval (letvar("fact", 
                fix (abs("f",
                         abs("n",
                             ite(equal(var "n",num 0),
                                 num 1,
                                 times(app(var "f",minus(var "n",num 1)),var "n")
                                )
                            )
                        )
                    ),
                app(var "fact", num 5)
               )
         )
         emptyenv ;
The code of the interpreter and of the example are here.

Environment-based operational semantics of lazy PCF

The main difference with respect to the eager operational semantics is that the argument of an application is not evaluated at the moment in which the application is evaluated, but only at the moment in which such argument is needed. Since the environment may change in the meanwhile, we need to form a closure also in this case.

The rule for application in the lazy semantics, therefore, is the following:

    r |- M eval (\x.P,r')   r'[x|->(N,r)] |- P eval R   
   ---------------------------------------------------
                  r |- (M N) eval R      
All the other rules remain the same.

Appendix: ML interpreter for the eager PCF with a better treatment of the error cases

After writing the interpreter above, I realized that it would not require much effort to make it more "user friendly" in the treatment of the erroneous cases. Essentially, we want the interpreter to give a result "error" in all the cases in which something goes wrong in the evaluation process (instead of aborting the evaluation). Note that a further refinement would be to add specific error messages, telling what exactly went wrong. We leave this further refinement as an exercise. The interpreter defined below just gives an error with no specific error message.
   (* Interpreter of eager PCF in ML *********************************** *)

   (* datatype term (same as before)************************************ *)

   datatype term = var of string           	  (* variables *)
                 | abs of string * term    	  (* abstractions *)
                 | app of term * term      	  (* applications *)
                 | num of int              	  (* numbers *)
                 | tt | ff                 	  (* booleans *)
                 | plus of term * term     	  (* aritmetical operation *)
                 | minus of term * term    	  (* aritmetical operation *)
                 | times of term * term    	  (* aritmetical operation *)
                 | divis of term * term    	  (* aritmetical operation *)
                 | equal of term * term           (* comparison operation *)
                 | ite of term * term * term      (* conditional *)
                 | letvar of string * term * term (* local declaration *)
                 | fix of term   	    	  (* fixpoint *)
                 | closure of term * (string -> term) (* closure *)
                 | error;                         (* erroneous situation *)


   (* type environment (same as before) ******************************* *)

   type environment = string -> term;

   val emptyenv:environment = fn x => error;

   fun update (r:environment) (x:string) (M:term) = 
                   (fn y => if y = x then M else r y):environment; 

   (* Function eval **************************************************** *)

   fun eval (var x) r 	   = eval (r x) r
     | eval (abs(x,M)) r   = closure(abs(x,M),r)
     | eval (app(M,N)) r   = ( case eval M r of
                                    closure(abs(x,M1),r1) => eval M1 (update r1 x (eval N r))
                                  | x => error ) (* any other case is an error *)
     | eval (num n) r      = (num n)
     | eval tt r           = tt
     | eval ff r           = ff
     | eval (plus(M,N)) r  = ( case (eval M r, eval N r) of
                                    (num m, num n) => num(m + n)
                                  | x => error )                       
     | eval (minus(M,N)) r = ( case (eval M r, eval N r) of
                                    (num m, num n) => num(m - n)
                                  | x => error )   
     | eval (times(M,N)) r = ( case (eval M r, eval N r) of
                                    (num m, num n) => num(m * n)
                                  | x => error )   
     | eval (divis(M,N)) r = ( case (eval M r, eval N r) of
                                    (num m, num n) => num(m div n)
                                  | x => error )   
     | eval (equal(M,N)) r = ( case (eval M r, eval N r) of
                                    (num m, num n) => if m = n then tt else ff
                                  | x => error )   
     | eval (ite(M,N,P)) r = ( case (eval M r) of 
                                    tt => eval N r 
                                  | ff => eval P r
                                  | x => error )
     | eval (letvar(x,M,N)) r  = eval N (update r x (eval M r ))
     | eval (fix M) r      = ( case eval M r of
                                    closure(abs(x,M1),r1) => 
                                       eval M1 (update r1 x (closure(fix M,r)))
                                  | x => error )
     | eval (closure(M,r1)) r  = eval M r1
     | eval error r        = error;
Note: Instead of using a case statement, we could have used exceptions

Exercise: Error messages

Modify the interpreter in such a way that, each time an error occurs, it gives an error message specifying the cause of the error. This can be done by defining the error case (in the datatype term) as
error of string
Then, each time an error is generated, the result of eval should be
error(< cause of the error >)
Like for instance
   val emptyenv:environment = fn x => error("undeclared variable");