Lecture 19 — 2018-03-27

Lambda calculus: evaluation and step functions, nontermination

This lecture is written in literate Haskell; you can download the raw source.

The equational theory is the “true” account of what the lambda calculus means, but it’s not a great account of how we interpret the lambda calculus as a programming language. There are several ways to do so.

Evaluation function

One way we can understand the lambda calculus is by writing a mathematical function to interpret it, like we did for arithmetic expressions. Here’s a first attempt:

eval :: LC -> LC
eval(x)     = ???             -- what do we do here?
eval(λx. e) = λx.e            -- evaluate e?
eval(e1 e2) = eval(e'[e2/x])  -- evaluate e2?
  where eval(e1) = λx. e'

Let’s deal with each of the questions in turn. Do we need a case in eval for variables? If we’re trying to understand our eval function as running a program, then we only ever want to run closed expressions—and the substitution in the application case makes sure we close up all of our terms.

Next, should we evaluate inside of a lambda? While the equational theory leaves us free to work on any part of the term, programming languages generally avoid evaluating inside of functions before they’ve been called. Consider the following Java method:

public void doStuff() {
    throw new RuntimeException("but i'm tired");

If my code never calls doStuff, then I should never see the exception. That is, we don’t evaluate inside of a function until its called. (There are evaluation schemes that do look inside functions; such reductions are called “full β”, and they’re not relevant for us.)

We’ve settled the first two questions with “we’ll never have to worry about it” and “don’t jump the gun on evaluating functions”. What should we do with the argument to functions? Should we evaluate the argument before substituting it into the body or not?

Here, we simply have a choice of calling convention. In call-by-value languages, we only call functions with fully evaluated arguments; in call-by-name languages, we don’t both evaluating arguments, figuring we’ll get to them when we need to. So there are two definitions of eval:

evalCBV :: LC -> LC
evalCBV(λx. e) = λx. e
evalCBV(e1 e2) = evalCBV(e'[e2'/x])
  where evalCBV(e1) = λx. e'
        evalCBV(e2) = e2'

evalCBN :: LC -> LC
evalCBN(λx. e) = λx. e
evalCBN(e1 e2) = evalCBN(e'[e2/x])
  where evalCBN(e1) = λx. e'

CBV and CBN differ in terms of termination: sometimes CBN terminates when CBN doesn’t. Our example was the following term e:

ω = λx. x x
Ω = ω ω
true = λa b. a
one = λs z. s z
e = true one Ω

In particular, evalCBN(e) = one but evalCBV(e) was undefined.

Step relations

We can also interpret the lambda calculus using a step relation. To do so, we’ll need to identify what it means to be “fully evaluated”. We say that the values of the lambda calculus are lambdas—they’re the objects in the language that don’t have more evaluation to do (until they’re called). We write v instead of e when we mean not just any term, but a value.

We can define the step relations for both CBN and CBV styles. First, here are the rules for the CBV lambda calculus, writing e -->CBV e' when e steps to e' under call-by-value semantics:

e1 -->CBV e1'
e1 e2 -->CBV e1' e2

e2 -->CBV e2'
v1 e2 -->CBV v1 e2'

(λx. e1) v2 -->CBV e1[v2/x]

Note that we’ve defined a left-to-right evaluation order. We could have defined a right-to-left order by making sure e2 -->CBV* v2 before we stepped e1. Recall that * takes the reflexive transitive closure of a relation, i.e.:

e -->CBV* e

e -->CBV e'     e' -->CBV* e''
e -->CBV* e''

The CBN relation is similar, but it doesn’t bother to evaluate its arguments:

e1 -->CBN e1'
e1 e2 -->CBN e1' e2

(λx. e1) e2 -->CBN e1[e2/x]


Let ω = λx. x x. Consider Ω = ω ω. Observe, using either -->CBV or -->CBN:

  Ω   = ω ω
      = (λx. x x) (λx. x x)
    --> (λx. x x) (λx. x x)
    --> (λx. x x) (λx. x x)
    --> ...

We’ve found a term that runs forever… yikes! What does evalCBV(Ω) do?

evalCBV(Ω) = evalCBV(ω ω)
           = evalCBV((λx. x x) (λx. x x))
           = evalCBV((x x)[(λx. x x)/x])
           = evalCBV((λx. x x) (λx. x x))

Uh oh… evalCBV isn’t well defined here. (Neither is evalCBN—check for yourself!) In fact, any evaluator for the lambda calculus must be a partial function—the lambda calculus is a universal computing machine, so it suffers from the undecidability of the Halting problem. We’d be in seriously contradictory territory if we were able to write a total function that evaluated every possible lambda calculus expression!

Note that step functions let us reason clearly about nontermination: we can say that a term e diverges when for all e' such that e -->* e', there exists an e'' such that e' --> e''.

Evaluation relations

The step functions let us reason clearly about nontermination, but they don’t let us “evaluate” a program in one go. We can define an evaluation relation that does just that while avoiding the definitional issues in evalCBV and evalCBN. We’ll say e ==>CBV v when e evaluates to v under call-by-value semantics.

λx. e ==>CBV λx. e

e1 ==>CBV λx. e    e2 ==>CBV v2    e[v2/x] ==> v
e1 e2 ==>CBV v

And for CBN:

λx. e ==>CBN λx. e

e1 ==>CBN λx. e    e[e2/x] ==> v
e1 e2 ==>CBN v

It’s worth running some examples (see lecture 17) through this semantics just to make sure you understand.

Just to understand how our evaluation relations deal with nontermination… can you build a finite derivation for Ω ==>CBV v for some value v?


Finally, there’s a style of interpreter that gets a little closer to how languages are actually implemented: real programming languages don’t typically use substitution directly, but instead keep an environment mapping variable names to values.

A brief aside: environments are like the stack, holding values that are in scope now but might not be later; stores are like the heap, holding values that persist (and might be mutated!) across function calls.

The main advantage of using environment is one of efficiency: substitution is a slow operation, walking over the whole term every time.

We begin by defining environments and values mutually recursively. Environments are finite maps from variable names to values. The only kind of value we’ll have are lambdas, just like above, but we’ll need to keep alongside each lambda an environment with all of the substitutions we would have done. Such a pair of a lambda and an environemnt is called a closure: the environment “closes up” the lambda abstraction.

ρ ::= . | ρ[x |-> v]
v ::= <λx. e, ρ>

Now we can define our evaluation relation as follows:

ρ(x) = v
ρ, x ==>CBV v

ρ, λx. e ==>CBV <λx. e, ρ>

ρ e1 ==>CBV <λx. e, ρ'>    ρ, e2 ==>CBV v2    ρ'[x|->v2], e ==> v
ρ, e1 e2 ==>CBV v

Try seeing how (λa b. a) zero one evaluates in the empty environment.

We can go from values back to terms like so:

toExpr(v) :: LC
toExpr(<λx. e, ρ>) = close(λx. e, ρ)

close(e, ρ) :: LC
close(x, ρ) = toExpr(ρ(x))
close(e1 e2, ρ) = close(e1, ρ) close(e2, ρ)
close(λx. e, ρ) = λx. close(e, ρ\x)

Note that ρ\x should be read as “the environment ρ but without any binding for the variable x”.

Tying it all together

Each of these definitions should in some sense be equivalent. Formally, the following statements should either all be true or all be false:

  1. evalCBV(e) = v
  2. e -->CBV* v
  3. e ==>CBV v
  4. ., e ==>CBV v' and toExpr(v') = v

Note that (2) and (3) are really very closely related: their implementation in code is nearly identical. All three implementation styles—evaluation function using substitution, step function, or evaluation function using closures—are good ones for HW06.