Lecture 6: The Substitution Model of Evaluation

In this lecture, we examine more closely how OCaml programs are evaluated. We build a more formal and precise description of the evaluation process. This is a model of evaluation based on the basic notion of substitution, in which variable names are replaced by values that they are bound to. This corresponds to the mathematical notion that two equal things are interchangeable.

A tricky example

What is the value of the following expression? (Note that this is not just a definition of a function. It binds two names evil and dummy to functions and then applies evil to three arguments, returning the value of that expression. The names evil and dummy are bound only in the body and not at top level.)

We can see that the function evil calls itself recursively, and the result of the function is the result when it is called with n = 1. But what are the values returned by the applications of the functions f, f1 and f2? To understand what those values are, we need to better understand the OCaml evaluation model, and how variable names like n are bound.

Evaluation

The OCaml prompt lets you type either a term or a declaration that binds a variable to the value of a term. It evaluates the term to produce a value, which is a term that does not need any further evaluation. We can define values as a syntactic class too. Values include not only constants, but tuples of values, variant constructors applied to values, and functions.

Running an OCaml program is just evaluating a term. What happens when we evaluate a term? In an imperative (non-functional) language like Java, we sometimes imagine that there is an idea of a "current statement" that is executing. This isn't a very good model for OCaml; it is better to think of OCaml programs as being evaluated in the same way that you would evaluate a mathematical expression. For example, if you see an expression like (1 + 2) * 4, you know that you first evaluate the subexpression 1 + 2, getting a new expression 3 * 4. Then you evaluate 3 * 4. OCaml evaluation works the same way. As each point in time, the OCaml evaluator rewrites the program expression to another expression. Assuming that evaluation eventually terminates, eventually the whole expression is a value and then evaluation stops: the program is done. Or maybe the expression never reduces to a value, in which case you have an infinite loop.

Rewriting works by performing simple steps called reductions. In the arithmetic example above, the rewrite is performed by doing the reduction 1 + 23 within the larger expression, replacing the occurrence of the subexpression 1 + 2 with the right-hand side of the reduction, 3, therefore rewriting (1 + 2) * 4 to 3 * 4.

The next question is which reduction OCaml does. Fortunately, there is a simple rule. Evaluation works by always performing the rightmost reduction that is allowed. So we can describe evaluation precisely by simply describing all the allowed reductions.

OCaml has a bunch of built-in reduction rules that go well beyond simple arithmetic. For example, consider the if expression. It has two important reduction rules:

if true then e1 else e2e1
if false then e1 else e2e2

If the evaluator runs into an if expression, the first thing it does is try to reduce the conditional expression to either true or false. Then it can apply one of the two rules here.

For example, consider the term

if 2 = 3 then "hello" else "good" ^ "bye"

This term evaluates as follows:

if 2 = 3 then "hello" else "good" ^ "bye"
→ if false then "hello" else "good" ^ "bye"
→ "good" ^ "bye"
→ "goodbye"

Notice that the term "good" ^ "bye" isn't evaluated to produce the string value "goodbye" until the if term is removed. This is because if is lazy about evaluating its then and else clauses.

Evaluating the let term

The rewrite rule for the let expression introduces a new issue: how to deal with the bound variable. In the substitution model, the bound variable is replaced with the value that it is bound to. Evaluation of the let works by first evaluating all of the bindings. Then those bindings are substituted into the body of the let expression (the expression after the in). For example, here is an evaluation using let:

let x = 1 + 4 in x * 3
→  let x = 5 in x * 3
→  5 * 3
→  15

Notice that the variable x is only substituted once there is a value (5) to substitute. That is, OCaml eagerly evaluates the binding for the variable. Most languages (e.g., Java) work this way. However, in a lazy language like Haskell, the term 1 + 4 would be substituted for x instead. This could make a difference if evaluating the term could create an exception, side effect, or an infinite loop.

Therefore, we can write the rule for rewriting let roughly as follows:

let x = v in e
e (with unbound occurrences of x replaced by v)

Remember that we use e to stand for an arbitrary expression (term), x to stand for an arbitrary identifier. We use v to stand for a value—that is, a fully evaluated term. By writing v in the rule, we indicate that the rewriting rule for let cannot be used until the term bound to x is fully evaluated. Values can be constants, applications of datatype constructors or tuples to other values, or anonymous function expressions. In fact, we can write a grammar for values:

v ::= c  |  X(v)   |   (v1,...,vn)  |   fun p -> e

Substitution

We wrote "with unbound occurrences of x replaced by v" above. The adjective "unbound" conveys an important but subtle issue. The term e may contain occurrences of x whose binding occurrence is not this binding x = v. It doesn't make sense to substitute v for these occurrences. For example, consider evaluation of the expression:

let x : int = 1 in
let f x = x in
let y = x + 1 in
fun (a : string) -> x * 2

The next step of evaluation replaces only the magenta occurrences of x with 1, because these occurrences have the first declaration as their binding occurrence. Notice that the two occurrences of x inside the function f, which are respectively a binding and a bound occurrence, are not replaced. Thus, the result of this rewriting step is:

let f x = x in
let y = 1 + 1 in
fun (a : string) -> 1 * 2

Let's write e{v/x} to mean the expression e with all unbound occurrences of x replaced by the value v. This operation is called substitution. Then we can restate the rule for evaluating let more simply:

let x = v in e        e{v/x}

This works because any occurrence of x in e is bound by exactly this declaration x = v.

Here are some examples of substitutions:

x{2/x}  =  2
x{2/y}  =  x
(fun y -> x) {"hi"/x} =  (fun y -> "hi")
(fun x -> x) {"hi"/x} =  (fun x -> x)
(f x) { fun y -> y / f }  =  ((fun y -> y) x)

One of the features that makes OCaml unusual is the ability to write complex patterns containing binding occurrences of variables. Pattern matching in OCaml causes these variables to be bound in such a way that the pattern matches the supplied value. This is can be a very concise and convenient way of binding variables. We can generalize the notation for substitution by writing e{v/p} to denote the expression e with all unbound occurrences of variables appearing in the pattern p replaced by the values obtained when p is matched against v. Examples:

y{(1, 2)/(x, y)}  =  2
(hd + 1 :: f tl){[7; 6; 3]/hd :: tl}  =  7 + 1 :: f [6; 3]

If a let expression introduces multiple declarations, we must substitute for all the bound variables simultaneously, once their bindings have all been evaluated.

Evaluating functions

Function application is the most interesting case. When a function is applied, OCaml substitutes the values passed as arguments for the parameters in the body of the function. Suppose we define a function abs as follows:

let abs (r : float) : float =
if r < 0. then -. r else r

We would like the evaluation of abs (2. +. 1.) to proceed roughly as follows:

abs (2. +. 1.)
→  abs 3.
→  if 3. < 0. then -3. else 3.
→  if false then -3. else 3.
→  3.

In fact, we know that declaring a function is really just syntactic sugar for binding a variable to an anonymous function. So when we evaluate the declaration of abs above, we are really binding the identifier abs to the value fun r -> if r < 0. then -. r else r.

Therefore, the evaluation of a function call proceeds as in the following example:

let abs r =
if r < 0. then -. r else r
in
abs (2. +. 1.)
→  (fun r -> if r < 0. then -. r else r) (2. +. 1.)
(* replacing occurrences of abs in let body with anonymous function *)
→  (fun r -> if r < 0. then -. r else r) 3.
→  if 3. < 0. then -. 3. else 3.
(* replacing occurrences of r in function body with argument 3. *)
→  if false then -. 3. else 3.
→  3.

We can use the substitution operator to give a more precise rule for what happens when a function is called:

(fun x -> e) v  →  e{v/x}

Interestingly, this is the same result that we got from the expression let x = v in e. So this tells us that we can think of let as syntactic sugar for function application.

Some caveats

This is only a model for how OCaml expressions are evaluated. The truth is that the OCaml interpreter does not actually perform the substitutions. But the substitution model is accurate for describing purely functional execution (that is, when there are no side effects or mutable objects). The actual operation of the OCaml interpreter and compiler is a bit more complex and not that important to understand for now. The goal here is to allow us as programmers to understand what the program is going to do. We can do that much more clearly in terms of substitution than by thinking about the machine code, or for that matter in terms of the transistors in the computer and the electrons inside them. This evaluation model is an abstraction that hides some of the details you don't really need to know about.

Some aspects of the model should not be taken too literally. For example, you might think that function calls take longer if an argument variable appears many times in the body of the function. It might seem that a call to a function f are faster if it is defined as fun f x = x * 2 rather than as fun f x = x + x because the latter requires two substitutions. Actually the time required to pass arguments to a function typically depends only on the number of arguments. In reality, both definitions would be equally fast.

Recursion

The model as given also has one significant weakness: It doesn't explain how recursive functions work. The problem is that a recursive function is in scope within its own body. Mutually recursive functions are also problematic, because each mutually recursive function is in scope within the bodies of all the others.

One way to understand this is that a recursive function can be "unrolled" by substituting the entire function for the name of the function in the body, and the resulting function is equivalent to the original. For example, we can define the factorial function

let rec fact n = if n = 0 then 1 else n * fact (n - 1)

or equivalently,

let rec fact = fun n -> if n = 0 then 1 else n * fact (n - 1)

The rec keyword indicates that the fact in the body refers to the entire function

fun n -> if n = 0 then 1 else n * fact (n - 1)

But the definition seems circular. The function fact is defined in terms of itself. We can "unroll" the function once by substituting the entire function for fact in the body, which would give

fun n -> if n = 0 then 1 else n *
(fun n -> if n = 0 then 1 else n * fact (n - 1)) (n - 1)

and this is equivalent to the original. We can unroll as many times as we like:

fun n -> if n = 0 then 1 else n *
(fun n -> if n = 0 then 1 else n *
(fun n -> if n = 0 then 1 else n *
(fun n -> if n = 0 then 1 else n *
fact (n - 1))
(n - 1))
(n - 1))
(n - 1)

and this is equivalent to the original. However, note that if we unroll finitely many times, no matter how many, there is always a free occurrence of fact in the body, so it still seems like we have a circular definition.

But say we could unroll infinitely many times. Then this would give an infinite anonymous function with no free occurrence of fact in the body:

fun n -> if n = 0 then 1 else n *
(fun n -> if n = 0 then 1 else n *
(fun n -> if n = 0 then 1 else n *
(fun n -> if n = 0 then 1 else n *
(...) (n - 1))
(n - 1))
(n - 1))
(n - 1)

Let's call this thing F. This is rather an unconventional object, since it is not a finite expression, but an infinite expression. However, whatever it is, it does satisfy the equation

F  =  fun n -> if n = 0 then 1 else n * F (n - 1)

and this is what we are binding to fact with the let rec declaration. As equational logic allows substitution of equals for equals, this justifies the unrolling operation.

It's probably easier to think of F as an anonymous function that hasn't been infinitely unrolled, but rather contains a pointer to itself that expands out into the same full anonymous function whenever it is used: When a let rec f = e in e' is evaluated, a fresh variable f' is generated for the variable f (a variable is fresh if it appears nowhere else in the program), along with an equation

f'  =  e{f'/f}

which will typically only be applied as a reduction rule in the left-to-right direction, as

f'  →  e{f'/f}

Then we evaluate let rec f = e in e' as if it were let f = f' in e'.

The name f' stands for the value of the expression that the arrow points to the graphical representation above. If evaluation ever hits f', the reduction rule is applied.

For example, consider this difficult-to-understand code that is similar to the example above:

let rec f g n =
if n = 1 then g 0
else g 0 + f (fun x -> n) (n - 1)
in f (fun x -> 10) 3

Can you predict what the result will be? It is evaluated as follows. If you can follow this then you really understand the substitution model!

We introduce a fresh symbol f' for the recursive function bound in the let rec expression, along with the reduction rule

f'  →  fun g -> fun n ->
if n = 1 then g 0
else g 0 + f' (fun x -> n) (n - 1)

then evaluate

let f = f' in f (fun x -> 10) 3

The evaluation then proceeds as follows.

let f = f' in f (fun x -> 10) 3
→  f' (fun x -> 10) 3
→  (fun g -> fun n ->
if n = 1 then g 0
else g 0 + f' (fun x -> n) (n - 1)) (fun x -> 10) 3
→  (fun n ->
if n = 1 then (fun x -> 10) 0
else (fun x -> 10) 0 + f' (fun x -> n) (n - 1)) 3
→  if 3 = 1 then (fun x -> 10) 0
else (fun x -> 10) 0 + f' (fun x -> 3) (3 - 1)
→  if false then (fun x -> 10) 0
else (fun x -> 10) 0 + f' (fun x -> 3) (3 - 1)
→  (fun x -> 10) 0 + f' (fun x -> 3) (3 - 1)
→  10 + f' (fun x -> 3) (3 - 1)
→  10 + (fun g -> fun n -> ...) (fun x -> 3) (3 - 1)
→  10 + (fun n -> ...) 2
→  10 + if 2 = 1 then (fun x -> 3) 0
else (fun x -> 3) 0 + f' (fun x -> 2) (2 - 1)
→  10 + if false then (fun x -> 3) 0
else (fun x -> 3) 0 + f' (fun x -> 2) (2 - 1)
→  10 + (fun x -> 3) 0 + f' (fun x -> 2) (2 - 1)
→  10 + 3 + f' (fun x -> 2) (2 - 1)
→  10 + 3 + (fun g -> fun n -> ...) (fun x -> 2) (2 - 1)
→  10 + 3 + (fun n -> ...) 1
→  10 + 3 + if 1 = 1 then (fun x -> 2) 0 else ...
→  10 + 3 + if true then (fun x -> 2) 0 else ...
→  10 + 3 + (fun x -> 2) 0
→  10 + 3 + 2
→  15

In general, there might be multiple functions defined in a let rec. These are evaluated as follows:

let rec f1 = fun x1 -> e1
and f2 = fun x2 -> e2
...
and fn = fun xn -> en
in e'

→

e'{f1'/f1, ..., fn'/fn}
(with equations f1' = fun x1 -> e{f1'/f1,...,fn'/fn}, ...
fn' = fun xn -> e{f1'/f1,...,fn'/fn},
all fi fresh)

The tricky example revisited

Now we have the tools to return to the tricky example from above. Let's first consider an easier example, where the third parameter is 1 rather than 3 as above.

let rec evil (f1, f2, n) =
let f x = 10 + n in
if n = 1 then f 0 + f1 0 + f2 0
else evil (f, f1, n-1)
and dummy x = 1000
in evil (dummy, dummy, 1)

We introduce a fresh variable evil' denoting the recursive function bound to evil along with the reduction rule

evil'  →  fun (f1, f2, n) ->
let f x = 10 + n in
if n = 1 then f 0 + f1 0 + f2 0
else evil' (f, f1, n-1)

and the tricky example can be rewritten

let evil = evil'
and dummy = fun x -> 1000
in evil (dummy, dummy, 1)

Now evaluating this expression by substitution,

let evil = evil'
and dummy = fun x -> 1000
in evil (dummy, dummy, 1)
→  evil' ((fun x -> 1000), (fun x -> 1000), 1)
→  let f x = 10 + 1 in
if 1 = 1 then f 0 + (fun x -> 1000) 0 + (fun x -> 1000) 0
else evil' (f, (fun x -> 1000), 1 - 1)
→  (fun x -> 10 + 1) 0 + (fun x -> 1000) 0 + (fun x -> 1000) 0
→  2011

Now if we consider the case where evil is called with n=2 rather than n=1, things get a bit more interesting. Here we will write down just the reduction steps corresponding to the recursive calls to evil and the calculation of the final return value.

let evil = evil'
and dummy = fun x -> 1000
in evil (dummy, dummy, 2)
→  evil' ((fun x -> 1000), (fun x -> 1000), 2)
→  evil' ((fun x -> 10 + 2), (fun x -> 1000), 1)
→  (fun x -> 10 + 1) 0 + (fun x -> 10 + 2) 0 + (fun x -> 1000) 0
→  1023

let evil = evil'
and dummy = fun x -> 1000
in evil (dummy, dummy, 3)
→  evil' ((fun x -> 1000), (fun x -> 1000), 3)
→  evil' ((fun x -> 10 + 3), (fun x -> 1000), 2)
→  evil' ((fun x -> 10 + 2), (fun x -> 10 + 3), 1)
→  (fun x -> 10 + 1) 0 + (fun x -> 10 + 2) 0 + (fun x -> 10 + 3) 0
→  36

Lexical (static) vs. dynamic scoping

Variable names are substituted immediately throughout their scope when a function is applied or a let is evaluated. This means that whenever we see an occurrence of a variable, how that variable is bound is immediately clear from the program text: the variable is bound to the innermost binding occurrence in whose scope it occurs. This rule is called lexical (or static) scoping.

Let us apply this to the tricky example from earlier. The key question is what the variable n means within the functions f, f1, f2. Even though these variables are all bound to the function f, they are bound to versions of the function f that occurred in three different scopes, where the variable n was bound to 1, 2, and 3 respectively. For example, on the first entry to evil, the value 3 is substituted for the variable n within the function f (which ultimately becomes f2 on the third application on evil).

The most common alternative to lexical scoping is called dynamic scoping. In dynamic scoping, a variable is bound to the most recently bound version of the variable, and function values do not record how their variables such as n are bound. For example, in the language Perl, the equivalent of the example code would print 33 rather than 36, because the most recently bound value for the variable n is 1. Dynamic scoping can be confusing because the meaning of a function depends on the context where it is used, not where it was defined. Most modern languages use lexical scoping.