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.
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.
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.
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.
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 = exp3in exp4end
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]
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 = 39in 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.