Structs Impl PRG Lan
Structs Impl PRG Lan
Structs Impl PRG Lan
Section 6 Summary
Standard Description: This summary covers roughly the same material as the lecture videos and the ma-
terials (slides, code) posted with each video. It can help to read about the material in a narrative style and
to have the material for an entire section of the course in a single document, especially when reviewing the
material later. Please report errors in these notes on the discussion board.
Contents
Datatype-Programming Without Datatypes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
Changing How We Evaluate Our Arithmetic Expression Datatype . . . . . . . . . . . . . . . 2
Recursive Datatypes Via Racket Lists . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Recursive Datatypes Via Racket’s struct . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
Why the struct Approach is Better . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
Implementing a Programming Language in General . . . . . . . . . . . . . . . . . . . . . . . . 6
Implementing a Programming Language Inside Another Language . . . . . . . . . . . . . . . 7
Assumptions and Non-Assumptions About Legal ASTs . . . . . . . . . . . . . . . . . . . . . . 8
Interpreters for Languages With Variables Need Environments . . . . . . . . . . . . . . . . . 9
Implementing Closures . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
Optional: Implementing Closures More Efficiently . . . . . . . . . . . . . . . . . . . . . . . . . 10
Defining “Macros” Via Functions in the Metalanguage . . . . . . . . . . . . . . . . . . . . . . 11
fun funny_sum xs =
case xs of
[] => 0
| (I i)::xs’ => i + funny_sum xs’
| (S s)::xs’ => String.size s + funny_sum xs’
In Racket, no such work-around is necessary, as we can just write functions that work for lists whose elements
are numbers or strings:
1
(define (funny-sum xs)
(cond [(null? xs) 0]
[(number? (car xs)) (+ (car xs) (funny-sum (cdr xs)))]
[(string? (car xs)) (+ (string-length (car xs)) (funny-sum (cdr xs)))]
[#t (error "expected number or string")]))
Essential to this approach is that Racket has built-in primitives like null?, number?, and string? for testing
the type of data at run-time.
But for recursive datatypes like this ML definition for arithmetic expressions:
datatype exp = Const of int | Negate of exp | Add of exp * exp | Multiply of exp * exp
fun eval_exp_old e =
case e of
Const i => i
| Negate e2 => ~ (eval_exp_old e2)
| Add(e1,e2) => (eval_exp_old e1) + (eval_exp_old e2)
| Multiply(e1,e2) => (eval_exp_old e1) * (eval_exp_old e2)
The type of eval_exp_old is exp -> int. In particular, the return type is int, an ML integer that we can
then add, multiply, etc. using ML’s arithmetic operators.
For the rest of this module, we will instead write this sort of function to return an exp, so the ML type
will become exp -> exp. The result of a call (including recursive calls) will have the form Const i for
some int i, e.g., Const 17. Callers have to check that the kind of exp returned is indeed a Const, extract
the underlying data (in ML, using pattern-matching), and then themselves use the Const constructor as
necessary to return an exp. For our little arithmetic language, this approach leads to a moderately more
complicated program:
2
in
case e of
Const _ => e (* notice we return the entire exp here *)
| Negate e2 => Const (~ (get_int (eval_exp_new e2)))
| Add(e1,e2) => Const ((get_int (eval_exp_new e1)) + (get_int (eval_exp_new e2)))
| Multiply(e1,e2) => Const ((get_int (eval_exp_new e1)) * (get_int (eval_exp_new e2)))
end
This extra complication has little benefit for our simple type exp, but we are doing it for a very good
reason: Soon we will be defining little languages that have multiple kinds of results. Suppose the result of
a computation did not have to be a number because it could also be a boolean, a string, a pair, a function
closure, etc. Then our eval_exp function needs to return some sort of one-of type and using a subset of the
possibilities defined by the type exp will serve our needs well. Then a case of eval_exp like addition will
need to check that the recursive results are the right kind of value. If this check does not succeed, then the
line of get_int above that raises an exception gets evaluated (whereas for our simple example so far, the
exception will never get raised).
(As an orthogonal note, we have not seen the syntax ’foo before. This is a Racket symbol. For our purposes
here, a symbol ’foo is a lot like a string "foo" in the sense that you can use any sequence of characters,
3
but symbols and strings are different kinds of things. Comparing whether two symbols are equal is a fast
operation, faster than string equality. You can compare symbols with eq? whereas you should not use eq?
for strings. We could have done this example with strings instead, using equal? instead of eq?.)
We can now write a Racket function to “evaluate” an arithmetic expression. It is directly analogous to the
ML version defined in eval_exp_new, just using our helper functions instead of datatype constructors and
pattern-matching:
(define (eval-exp e)
(cond [(Const? e) e] ; note returning an exp, not a number
[(Negate? e) (Const (- (Const-int (eval-exp (Negate-e e)))))]
[(Add? e) (let ([v1 (Const-int (eval-exp (Add-e1 e)))]
[v2 (Const-int (eval-exp (Add-e2 e)))])
(Const (+ v1 v2)))]
[(Multiply? e) (let ([v1 (Const-int (eval-exp (Multiply-e1 e)))]
[v2 (Const-int (eval-exp (Multiply-e2 e)))])
(Const (* v1 v2)))]
[#t (error "eval-exp expected an exp")]))
(define test-exp (Multiply (Negate (Add (Const 2) (Const 2))) (Const 7)))
(define test-ans (eval-exp test-exp))
This defines a new “struct” called foo that is like an ML constructor. It adds to the environment functions
for constructing a foo, testing if something is a foo, and extracting the fields bar, baz, and quux from a foo.
The names of these bindings are formed systematically from the constructor name foo as follows:
• foo is a function that takes three arguments and returns a value that is a foo with a bar field holding the
first argument, a baz field holding the second argument, and a quux field holding the third argument.
• foo? is a function that takes one argument and returns #t for values created by calling foo and #f for
everything else.
• foo-bar is a function that takes a foo and returns the contents of the bar field, raising an error if
passed anything other than a foo.
• foo-baz is a function that takes a foo and returns the contents of the baz field, raising an error if
passed anything other than a foo.
4
• foo-quux is a function that takes a foo and returns the contents of the quux field, raising an error if
passed anything other than a foo.
There are some useful attributes we can include in struct definitions to modify their behavior, two of which
we discuss here.
First, the #:transparent attribute makes the fields and accessor functions visible even outside the module
that defines the struct. From a modularity perspective this is questionable style, but it has one big advantage
when using DrRacket: It allows the REPL to print struct values with their contents rather than just as an
abstract value. For example, with our definition of struct foo, the result of (foo "hi" (+ 3 7) #f) prints
as (foo "hi" 10 #f). Without the #:transparent attribute, it would print as #<foo>, and every value
produced from a call to the foo function would print this same way. This feature becomes even more useful
for examining values built from recursive uses of structs.
Second, the #:mutable attribute makes all fields mutable by also providing mutator functions like set-foo-bar!,
set-foo-baz!, and set-foo-quux!. In short, the programmer decides when defining a struct whether the
advantages of having mutable fields outweigh the disadvantages. It is also possible to make some fields
mutable and some fields immutable.
We can use structs to define a new way to represent arithmetic expressions and a function that evaluates
such arithmetic expressions:
(define (eval-exp e)
(cond [(const? e) e] ; note returning an exp, not a number
[(negate? e) (const (- (const-int (eval-exp (negate-e e)))))]
[(add? e) (let ([v1 (const-int (eval-exp (add-e1 e)))]
[v2 (const-int (eval-exp (add-e2 e)))])
(const (+ v1 v2)))]
[(multiply? e) (let ([v1 (const-int (eval-exp (multiply-e1 e)))]
[v2 (const-int (eval-exp (multiply-e2 e)))])
(const (* v1 v2)))]
[#t (error "eval-exp expected an exp")]))
Like with our previous approach, nothing in the language indicates how arithmetic expressions are defined
in terms of constants, negations, additions, and multiplications. The structure of this version of eval-exp is
almost identical to the previous version, just using the functions provided by the struct definitions instead
of our own list-processing functions. Defining expressions using the constructor functions is also similar:
(define test-exp (multiply (negate (add (const 2) (const 2))) (const 7)))
(define test-ans (eval-exp test-exp))
5
(struct add (e1 e2) #:transparent)
the function add returns things that cause add? to return #t and every other type-testing function like
number?, pair?, null?, negate?, and multiply? to return #f. Similarly, the only way to access the e1
and e2 fields of an add value is with add-e1 and add-e2 — trying to use car, cdr, multiply-e1, etc. is a
run-time error. (Conversely, add-e1 and add-e2 raise errors for anything that is not an add.)
Notice that our first approach with lists does not have these properties. Something built from the Add
function we defined is a list, so pair? returns #t for it and we can, despite it being poor style, access the
pieces directly with car and cdr.
So in addition to being more concise, our struct-based approach is superior because it catches errors sooner.
Using cdr or Multiply-e2 on an addition expression in our arithmetic language is almost surely an error,
but our list-based approach sees it as nothing more or less than accessing a list using the Racket primitives
for doing so. Similarly, nothing prevents an ill-advised client of our code from writing (list ’Add "hello")
and yet our list-based Add? function would return #t given the result list ’(Add "hello").
That said, nothing about the struct definitions as we are using them here truly enforces invariants. In
particular, we would like to ensure the e1 and e2 fields of any add expression hold only other arithmetic
expressions. Racket has good ways to do that, but we are not studying them here. First, Racket has a
module system that we can use to expose to clients only parts of a struct definition, so we could hide the
constructor function and expose a different function that enforces invariants (much like we did with ML’s
module system).1 Second, Racket has a contract system that lets programmers define arbitrary functions to
use to check properties of struct fields, such as allowing only certain kinds of values to be in the fields.
Finally, we remark that Racket’s struct is a powerful primitive that cannot be described or defined in terms
of other things like function definitions or macro definitions. It really creates a new type of data. The feature
that the result from add causes add? to return #t but every other type-test to return #f is something that
no approach in terms of lists, functions, macros, etc. can do. Unless the language gives you a primitive for
making new types like this, any other encoding of arithmetic expressions would have to make values that
cause some other type test such as pair? or procedure? to return #t.
similar features in other languages, put the lie to this. You do not need abstract types and static typing to enforce ADTs. It
suffices to have a way to make new types and then not directly expose the constructors for these types.
6
language implementation. If our language includes type-checking rules or other reasons that an AST may
still not be a legal program, the type-checker will use this AST to either produce error messages or not. The
AST is then passed to the rest of the implementation.
There are basically two approaches to this rest-of-the-implementation for implementing some programming
language B. First, we could write an interpreter in another language A that takes programs in B and
produces answers. Calling such a program in A an “evaluator for B” or an “executor for B” probably makes
more sense, but “interpreter for B” has been standard terminology for decades. Second, we could write a
compiler in another language A that takes programs in B and produces equivalent programs in some other
language C (not the language C necessarily) and then uses some pre-existing implementation for C. For
compilation, we call B the source language and C the target language. A better term than “compiler” would
be “translator” but again the term compiler is ubiquitous. For either the interpreter approach or the compiler
approach, we call A, the language in which we are writing the implementation of B, the metalanguage.
While there are many “pure” interpreters and compilers, modern systems often combine aspects of each and
use multiple levels of interpretation and translation. For example, a typical Java system compiles Java source
code into a portable intermediate format. The Java “virtual machine” can then start interpreting code in
this format but get better performance by compiling the code further to code that can execute directly on
hardware. We can think of the hardware itself as an interpreter written in transistors, yet many modern
processors actually have translators in the hardware that convert the binary instructions into smaller simpler
instructions right before they are executed. There are many variations and enhancements to even this multi-
layered story of running programs, but fundamentally each step is some combination of interpretation or
translation.
A one-sentence sermon: Interpreter versus compiler is a feature of a particular programming-language imple-
mentation, not a feature of the programming language. One of the more annoying and widespread misconcep-
tions in computer science is that there are “compiled languages” such as C and “interpreted languages” such
as Racket. This is nonsense: I can write an interpreter for C or a compiler for Racket. (In fact, DrRacket
takes a hybrid approach not unlike Java.) There is a long history of C being implemented with compilers
and functional languages being implemented with interpreters, but compilers for functional languages have
been around for decades. SML/NJ, for example, compiles each module/binding to binary code.
7
While embedding a language like arithmetic-expressions inside another language like Racket might seem
inconvenient compared to having special syntax, it has advantages even beyond not needing to write a
parser. For example, below we will see how we can use the metalanguage (Racket in this case) to write
things that act like macros for our language.
The new features include booleans (either true or false), conditionals, and a construct for comparing two
numbers and returning a boolean (true if and only if the numbers are the same). Crucially, the result of
evaluating an expression in this language could now be:
In other words, there are now two types of values in our language – numbers and booleans – and there are
operations that should fail if a subexpression evaluates to the wrong kind of value.
This last possibility is something an interpreter should check for and give an appropriate error message. If
evaluating some kind of expression (e.g., addition) requires the result of evaluating subexpressions to have a
certain type (e.g., a number like (const 4) and not a boolean like (bool #t)), then the interpreter should
check for this result (e.g., using const?) rather than assuming the recursive result has the right type. That
way, the error message is appropriate (e.g., “argument to addition is not a number”) rather than something
in terms of the implementation of the interpreter.
The code posted with the course materials corresponding to these notes has two full interpreters for this
language. The first does not include any of this checking while the second, better one does. Calling the first
interpreter eval-exp-wrong and the second one eval-exp, here is just the addition case for both:
; eval-exp-wrong
[(add? e)
(let ([i1 (const-int (eval-exp-wrong (add-e1 e)))]
[i2 (const-int (eval-exp-wrong (add-e2 e)))])
(const (+ i1 i2)))]
; eval-exp
[(add? e)
8
(let ([v1 (eval-exp (add-e1 e))]
[v2 (eval-exp (add-e2 e))])
(if (and (const? v1) (const? v2))
(const (+ (const-int v1) (const-int v2)))
(error "add applied to non-number")))]
However, eval-exp is assuming that the expression it is evaluating is a legal AST for the language. It
can handle (add (const 2) (const 2)), which evaluates to (const 4) or (add (const 2) (bool #f)),
which encounters an error, but it does not gracefully handle (add #t #f) or (add 3 4). These are not legal
ASTs, according to the rules we have in comments, namely:
It is reasonable for an interpreter to assume it is given a legal AST, so it is okay for it to “just crash” with
a strange, implementation-dependent error message if given an illegal AST.
• To evaluate a variable expression, it looks up the variable’s name (i.e., the string) in the environment.
• To evaluate most subexpressions, such as the subexpressions of an addition operation, the interpreter
passes to the recursive calls the same environment that was passed for evaluating the outer expression.
• To evaluate things like the body of a let-expression, the interpreter passes to the recursive call a slightly
different environment, such as the environment it was passed with one more binding (i.e., pair of string
and value) in it.
To evaluate an entire program, we just call our recursive helper function that takes an environment with the
program and a suitable initial environment, such as the empty environment, which has no bindings in it.
2 In fact, for languages with features like mutation or exceptions, the helper function needs even more parameters.
9
Implementing Closures
To implement a language with function closures and lexical scope, our interpreter needs to “remember” the
environment that “was current” when the function was defined so that it can use this environment instead
of the caller’s environment when the function is called. The “trick” to doing this is rather direct: We can
literally create a small data structure called a closure that includes the environment along with the function
itself. It is this pair (the closure) that is the result of interpreting a function. In other words, a function is not
a value, a closure is, so the evaluation of a function produces a closure that “remembers” the environment
from when we evaluated the function.
We also need to implement function calls. A call has two expressions e1 and e2 for what would look like
e1 e2 in ML or (e1 e2) in Racket. (We consider here one-argument functions, though the implementation
will naturally support currying for simulating multiple argument functions.) We evaluate a call as follows:
• We evaluate e1 using the current environment. The result should be a closure (else it is a run-time
error).
• We evaluate e2 using the current environment. The result will be the argument to the closure.
• We evaluate the body of the code part of the closure using the environment part of the closure
extended with the argument of the code part mapping to the argument at the call-site.
In the homework assignment connected to these course materials, there is an additional extension to the
environment for a variable that allows the closure to call itself recursively. But the key idea is the same: we
extend the environment-stored-with-the-closure to evaluate the closure’s function body.
This really is how interpreters implement closures. It is the semantics we learned when we first studied
closures, just “coded up” in an interpreter.
10
Defining “Macros” Via Functions in the Metalanguage
When implementing an interpreter or compiler, it is essential to keep separate what is in the language being
implemented and what is in the language used for doing the implementation (the metalanguage). For example,
eval-exp is a Racket function that takes an arithmetic-expression-language expression (or whatever language
we are implementing) and produces an arithmetic-expression-language value. So for example, an arithmetic-
expression-language expression would never include a use of eval-exp or a Racket addition expression.
But since we are writing our to-be-evaluated programs in Racket, we can use Racket helper functions to help
us create these programs. Doing so is basically defining macros for our language using Racket functions as
the macro language. Here is an example:
Here double is a Racket function that takes the syntax for an arithmetic expression and produces the
syntax for an arithmetic expression. Calling double produces abstract syntax in our language, much like
macro expansion. For example, (negate (double (negate (const 4)))) produces (negate (multiply
(negate (const 4)) (const 2))). Notice this “macro” double does not evaluate the program in any way:
we produce abstract syntax that can then be evaluated, put inside a larger program, etc.
Being able to do this is an advantage of “embedding” our little language inside the Racket metalanguage.
The same technique works regardless of the choice of metalanguage. However, this approach does not handle
issues related to variable shadowing as well as a real macro system that has hygienic macros.
Here is a different “macro” that is interesting in two ways. First the argument is a Racket list of language-
being-implemented expressions (syntax). Second, the “macro” is recursive, calling itself once for each element
in the argument list:
11