Lecture 9.3: Binding
Last class, we ended up with an expression AST like so:
type value = | Nat of int | Bool of bool
type op = | Add | Mul | And | Or | Not | Eq
type expr =
| Const of value
| Op of op * expr list
| Ite of expr * expr * expr
along with a semantics that allowed us to evaluate expressions to values:
While the type says we always return a value, recall that it throws an exception (via failwith) if there is a "type error" in the semantics (e.g., adding a number to a boolean).
This class, we will tackle a new challenge when it comes to growing our language for realism: adding let-binders, so we can have expressions like:
(* a single let expression *)
let x = 2 in x + 3
(* sequenced let expressions *)
let x = 2 in
let y = 3 in
let z = 3 in
x + y + z
(* nested let expressions *)
let x = (let y = 3 in y) in
let z = 3 in
x + z
y is "free" (not defined).
We can adapt for let-bindings and variables by adding these nodes to the AST :
type value = | Nat of int | Bool of bool
type op = | Add | Mul | And | Or | Not | Eq
type expr =
| Var of string
| Let of string * expr * expr (* Let(x, e1, e2) is "let x = e1 in e2" *)
| Const of value
| Op of op * expr list
| Ite of expr * expr * expr
Note two things: first, that our other nodes of the AST (e.g., Op) do not change; and that, even though we are using strings for variables, this is NOT the same as strings appearing inside the language itself. If we wanted the language to be able to manipulate strings, we would need to add a new type of value and new operators.
Let's write down some example programs from above:
From the perspective of parsing, free variables are fine (just like how false + false is a valid AST), so the following are also valid ASTs:
y
-->>> Var "y"
let x = 4 in y
-->>> Let("x", Const (Nat 4), Var "y")
let x = y in x
-->>> Let("x", Var "y", Var "x")
Interpreting Let Bindings
How should we evaluate a program like let x = 2 + 2 in x * x?
Let's think about what happens in OCaml: first, we evaluate 2 + 2, giving us 4; then, we now know that x = 4, which lets us evaluate x * x. , giving us 16. Let's think of a few more examples:
false, because the second binding shadows (overwrites) the first binding.
Here, y is out of scope outside of the inner let-binding. Thus, what this shows us is that bindings for variables don't flow out of the let expression that created it; but only flow down into the right-hand side of the let expression.
Note
This is one way of showing that OCaml, like most other languages, implements lexical scope: the knowledge of what variables are in scope is defined by the lexical structure of the source program.
Thus, what do we know?
- There needs to be a way to assign values to variables;
- When evaluating
let x = e1 in e2, we first evaluatee1, grab the value forx, and evaluatee2now with this new binding; and - When evaluating
let x = (let y = e0 in e1) in e2, we don't wantyto be in scope fore2.
There are multiple choices for how to implement a semantics that does this. The solution we will tackle here is to use environments, which are maps from variables to values.
First, take note of a few things:
- We implement the environment using OCaml's
MapAPI, documented here; - We need to change the type of
evalto take the environmentmas an input; - But we do not need to return an environment. This is because of our choice to implement lexical scope: the environment only flows down the tree; not back up.
Let's now sketch out eval, and see some of the various cases. First, let's see how the old cases work now:
let rec eval (e : expr) (m : value StringMap.t) : value =
match e with
| Const v -> v
| Var s -> ...
| Let (x, e1, e2) -> ...
| Op (o, es) -> let vs = List.map (fun e -> eval e m) es in eval_op o vs
| Ite (cond, e1, e2) ->
match eval cond m with
| Bool b -> if b then eval e1 m else eval e2 m
| _ -> failwith "Ite: didn't get a boolean"
When we evaluate an Op, the only thing that changes is that, instead of calling List.map eval es, we need to pass the map m in as the second argument to eval. In this way, we are passing the environment downwards to the subexpressions. The code for Ite works the exact same way.
Let's now see the other cases:
let rec eval (e : expr) (m : value StringMap.t) : value =
match e with
...
| Var s ->
(match StringMap.find_opt s m with
| Some v -> v
| None -> failwith "Variable not found"
)
| Let (x, e1, e2) ->
let v = eval e1 m in
eval e2 (StringMap.add x v m)
...
In the Var case, we look up the variable in the environment, and throw an exception if the variable isn't found.
In the Let case, we first evaluate e1 under the original environment, and then evaluate e2 under the environment extended with the new value for x.
The full code is given here. As an example piece of code, we can try evaluating
(* let x = (if true && false then 0 else 1) in (x + x) *)
let test_program =
Let ("x",
Ite (Op (And, [Const (Bool true); Const (Bool false)]),
Const (Nat 0),
Const (Nat 1)
),
Op (Add, [Var "x"; Var "x"]))
To evaluate this, we need to call eval. With no other variables in scope, we can just use StringMap.empty: