Lecture 10: The Evaluator (or, All About Mini-ML)

Administrivia

PS#3 is out, we hope you are hard at work on it. There is no part IV to the problem set.

The first prelim will focus on the RSM. Be sure you understand it! I will talk more about this at the end of next week. The evaluator will not be on the first prelim, but will have a starring role in prelim #2.

Evaluators

In PS#2 you worked on a calculator, which is a simple example of a REPL (Read-Evaluate-Print Loop). The basic idea is to write a loop that reads in a string, processes it and prints some value. In order to do this, it is most convenient to turn the string into some kind of representation that is more convenient to process than simply an array of characters. For both PS#2 and PS#3, the strings that are accepted come from a language (i.e., they actually have a BNF grammar). When the input is a language, like the language of arithmetic expressions (PS#2) or Mini-ML (PS#3), the representation that we will operate on is called an Abstract Syntax Tree, or AST.

What exactly is Mini-ML, and why are we playing with it? Well, Mini-ML is the input language for PS#3. It  is considerably more powerful than the input language for PS#2, and allows you to do various cool things (such as defining and using your own functions).

To a good first approximation, Mini-ML is just like SML. Why would we bother to do this? After all, in a certain precise sense this is completely useless! We are implementing an interpreter for mini-ML in SML (just as we implemented an interpreter for arithmetic expressions in PS#2). This means that for our interpreter to run, we need to already have an existing SML interpreter. It would be different if we wrote an SML interpreter in, say, C++… Moreover, the world already has many perfectly good SML interpreters; why do we need another one?

This is an important question, and has several answers:

·       The main point in CS312 is to ensure that students get a really good understanding of an easy-to-understand programming language. The best way by far is to understand the RSM. The evaluator is a program that essentially implements the RSM, so by writing it you ensure that you really understand the semantics of the language.

·       We can change the semantics of mini-ML, while we are stuck with the semantics of SML. For example, in SML 6*9=54, and that is the end of the story… There is no change to make this something more interesting. More seriously, though, we can explore some interesting language tradeoffs which are not quite the same as SML.

·       By writing a mini-ML interpreter, we can explore an important issue in software engineering, namely changing the internals of a program without changing its outware appearance. This is usually done for efficiency; you will explore such a trade-off in Problem 5 of PS#3. Later on in the course, we will use the mini-ML interpreter to explore some other such trade-offs.

Mini-ML versus SML

Now, Mini-ML is not precisely the same as SML. The reason is that SML is actually fairly complex, and we want our interpreter to be short and easy to read ( for instance, so that RDZ can ask questions about it on the exams…)

In particular, mini-ML supports only a subset of what SML does. Essentially, the subset is what I described when I went over the RSM, but it does not contain fun or pattern matching. The type system is also somewhat simpler, and more permissive (i.e. there are SML programs that would generate an error which are OK in SML). This is primarily because typing is done at run time. All of these are motivated by simplicity. To further simplify the discussion, I won’t go into how mini-ML handles types.

Overview of evaluate

Most of the actual evaluator is relatively straightforward. The main function is called evaluate, and most of the cases that it handles require very little thought.  There are three vital datatypes: expressions, values, and environments. For the moment, let us focus on what happens when we type expressions without variables at mini-ML. This will allow us to defer the discussion fo environments for a bit.

[If you think about it for a moment, you will realize that this is almost exactly PS#2. This is not a coincidence…]

The expression datatype is an AST, and is the output of the parser. . The possible constructors for this datatype all end in _e (example: Binop_e). However, expressions which evaluate to themselves have types that end in _c (example: Int_c).

The value datatype is pretty simple, and mostly encodes constants. The possible constructors for this datatype all end in _v (example: Int_v).

It is worth your time to trace through what happens when you type a number to mini-ML. Essentially, the parser turns the string 42 into the AST Int_c(42). The evaluator then turns this into the value Int_v(42). At the very end, the evaluator knows how to print out value types; it prints out 42 and goes back to reading more input.

Suppose that the expression that was typed at the mini-ML interpreter was E1+ E2, for some (variable-free) expressions E1, E2. The AST that will be generated for this is: Binop_e( e1, Plus, e2) for some AST e1, e2. To evaluate this, we simply evaluate e1, evaluate e2, add the two resulting integers, and return an Int_v.

Substitution, variables and environments

OK, now let us consider what happens with variables. Recall that the RSM tells us what the value is of an ML expression; we simply have to implement this. When we added variables to our language, we ended up substituting.

The heart of the RSM is simple to express: in order to evaluate a combination e1(e2), we evaluate both e1 and e2. The value of e1 will be fn(var)=>body, the value of e2 we weill call v2. The whole result is the result of evaluating body{v2/var}. Of course, when we substitute, we have to be somewhat careful, and only replace free occurrences of var.

It is possible to implement precisely this. However, it’s not very efficient. Instead of substituting for variables directly, we will simply maintain an environment, which is a mapping from identifiers to values. It’s easiest to understand this if we think of the substitutions as if they all come from let’s, rather then from combinations. Consider:

let val var1 = exp1 
    val var2 = exp2
    val var3 = exp3
in 
  exp4
end

Let the value of the expression exp1 = val1, exp2 = val2, exp3 = val3. Then by the RSM this is equivalent to exp4{val1/var1}{val2/var2}{val3/var3}. But if we actually did this is would be very slow (suppose that exp4 uses the variables many many times).

Instead we maintain an enviroment, which contains all the substitutions that we need to do. The contract of evaluate is now to evaluate an expression in an environment (i.e., with a set of substitutions).  It is important to remember this: it now makes no sense to talk about evaluating an expression; it only makes sense to evaluate an expression in an environment. Whenever we see an occurrence of a variable, we look it up in the environment to gets it value.

This actually works out extremely well, and we get the right behavior (in terms of shadowing) essentially for free.

In the example above, we evaluate the let in some environment. We then add the binding var1 = val1, var2 = val2, var3 = val3 to that environment, and in the new environment we evaluate the expression exp4.

Environments are only updated when we are looking at a combination or a let (as I said, these are basically the same thing).

[Trace the environments and the calls to eval(exp,env)  in the above example]

Closures

Before getting into how closures are handled in the evaluator, we need to think about a few subtleties of the RSM.  Because of the substitution model, when we evaluate a fn expression we essentially capture all the free variables that it uses. Example:

  let val x = 3
  in
    fn(y) => x + y
  end

This expression evaluates, by the RSM, to fn(y) => 3 + y. We can use it directly (i.e. substitute it anyplace we could say fn(y)=> 3 + y).

So for example we could say [warning: sample prelim question ahead!]

let val myfun = 
  (let val x = 3
  in
    fn(y) => x + y
  end)
  val x = 39
in
  myfun(x)
end
 

But remember that in the evaluator, we don’t do substitutions directly, we just keep a list of current substitutions.

So when we evaluate a fn expression in an environment, we need to hold on to the environment in which the expression is evaluated.