7. From Evaluator to Abstract Machine
One promise is left standing. The higher-order footnote of chapter 2 observed that its first-order closures are what a higher-order evaluator becomes under defunctionalization, and called running that derivation on its own evaluator a candidate ending for these lectures. This chapter is that ending. The recipe is the functional correspondence of Ager, Biernacki, Danvy, and Midtgaard: make the evaluator's control explicit by rewriting it in continuation-passing style, then trade the continuation functions for first-order data, and an abstract machine appears, not designed but discovered. No step involves a decision; the machine was in the evaluator all along.
The running heart of the evaluator
A machine runs closed programs. Types, neutrals, and quotation, the parts of chapter 2's evaluator that exist to check open terms, play no role at runtime, so the derivation works on the untyped heart. The syntax is chapter 6's Exp, four constructors of nameless lambda calculus with a unit; a value is the unit or a closure, a body paired with the environment it was written in, exactly as in chapter 2:
inductive Value where
| unit
| closure (env : List Value) (body : Exp)
deriving Repr, BEq
instance : Inhabited Value := ⟨.unit⟩The evaluator is chapter 2's eval with everything but running removed: a variable is a lookup, the two introduction forms are already values, and an application evaluates the function, evaluates the argument, and enters the closure.
partial def eval (env : List Value) : Exp -> Option Value
| .var i => env[i]?
| .unit => some .unit
| .lam body => some (.closure env body)
| .app fn arg => do
let vf <- eval env fn
let va <- eval env arg
let .closure cenv body := vf | none
eval (va :: cenv) bodyThe honesty discipline of chapter 1 and chapter 2 carries over unchanged: failure is a value, so an out-of-scope variable returns none, and the definition is partial, because the untyped calculus contains terms with no result at all. The classic one is on the page and, deliberately, never run:
def delta : Exp := .lam (.app (.var 0) (.var 0))
def omega : Exp := .app delta deltaThe rest of the computation, made a value
The first transformation makes the evaluator confess what it plans to do between recursive calls. In continuation-passing style every call receives one extra argument, the continuation, a function standing for the rest of the computation. A success returns only by calling it; the two refusals, an unbound variable and a non-function applied, keep chapter 1's shape and hand back none past the plan:
partial def evalK (env : List Value) (e : Exp)
(k : Value -> Option Value) : Option Value :=
match e with
| .var i => do k (<- env[i]?)
| .unit => k .unit
| .lam body => k (.closure env body)
| .app fn arg =>
evalK env fn fun vf =>
evalK env arg fun va => do
let .closure cenv body := vf | none
evalK (va :: cenv) body kRead the application case slowly; the whole chapter turns on it. Between evaluating the function and evaluating the argument, the evaluator waits with a definite plan: evaluate the argument next, then apply. Between evaluating the argument and entering the closure it waits with another: apply the function value already in hand. The direct-style evaluator kept those plans implicit in Lean's own call stack; this one writes each plan down as a lambda. Counting the lambdas shows exactly two shapes of plan ever arise, beyond the initial plan of handing the result back.
Defunctionalization
A function type inhabited by only finitely many lambdas can be replaced by an inductive type with one constructor per lambda, together with an apply function giving each constructor its meaning. The move is Reynolds's defunctionalization, and Danvy and Nielsen tour its uses; applied to the continuations here, it yields one constructor per waiting shape, and a continuation built by nesting becomes a list of frames, the machine's stack:
inductive Frame where
| evalArg (env : List Value) (arg : Exp)
| applyFn (fn : Value)
deriving Repr
abbrev Stack := List FrameThe payoff of chapter 2's footnote lands here. A closure is to a function value exactly what a frame is to a continuation: its defunctionalized, first-order record. The evaluator had already made that trade for its values; the machine merely finishes the job for its control.
The machine
What remains after both transformations is a first-order transition system. A state either evaluates an expression against an environment under a stack, or gives a value back to the top frame; each transition is one match arm, and a driver runs them to quiescence:
inductive State where
| going (env : List Value) (e : Exp) (stack : Stack)
| giving (stack : Stack) (v : Value)
deriving Repr
def step : State -> Option State
| .going env (.var i) s => do pure (.giving s (<- env[i]?))
| .going _ .unit s => pure (.giving s .unit)
| .going env (.lam body) s => pure (.giving s (.closure env body))
| .going env (.app fn arg) s => pure (.going env fn (.evalArg env arg :: s))
| .giving (.evalArg env arg :: s) vf => pure (.going env arg (.applyFn vf :: s))
| .giving (.applyFn (.closure cenv body) :: s) va =>
pure (.going (va :: cenv) body s)
| .giving (.applyFn .unit :: _) _ => none
| .giving [] _ => none
partial def run (s : State) : Option Value :=
match s with
| .giving [] v => some v
| s => do run (<- step s)
def machine (e : Exp) : Option Value := run (.going [] e [])The correspondence paper identifies the endpoint of this very derivation as Felleisen et al.'s CEK machine, named for its three components: control, environment, continuation. Nothing above was written to match it; the machine fell out. A bounded tracer makes the run visible. In the trace below, []\.0 is a closure over the empty environment, and the seven states walk the identity applied to the unit from program to answer:
def idExp : Exp := .lam (.var 0)
def applyId : Exp := .app idExp .unit
/--
info: eval (\.0 unit) env [] stack []
eval \.0 env [] stack [arg(unit)]
give []\.0 stack [arg(unit)]
eval unit env [] stack [apply([]\.0)]
give unit stack [apply([]\.0)]
eval 0 env [unit] stack []
give unit stack []
-/
#guard_msgs in
#eval IO.println <| String.intercalate "\n"
((trace 12 (.going [] applyId [])).map showState)The agreement, checked
The derivation's claim is that nothing observable changed, and the build pins it on closed programs, a twice-application among them: evaluator and machine return the same value, and they refuse the same scope errors.
def twiceExp : Exp :=
.lam (.lam (.app (.var 1) (.app (.var 1) (.var 0))))
def demo : Exp := .app (.app twiceExp idExp) .unit
#guard eval [] applyId == machine applyId
#guard eval [] demo == machine demo
#guard eval [] (.app idExp idExp) == machine (.app idExp idExp)
#guard (eval [] (.var 0)).isNone && (machine (.var 0)).isNoneThe guards state agreement only on the programs they run; both sides are partial functions, and the statement for all programs belongs to the correspondence paper. What this chapter shows is smaller and pointed: the gap between an interpreter and a machine is two mechanical transformations wide.
The same walk exists in a smaller landscape. Hutton's tutorial Programming language semantics: it's easy as 1,2,3 takes the language of integers and addition, nothing else, through the standard semantic styles, and its eighth section derives an abstract machine with exactly this chapter's two moves, continuations and then defunctionalization. A reader who wants the derivation once more, slower and with nothing but numbers in the language, will find it there.
And that closes the arc of these lectures. A parser was assembled by hand, a checker was built on normalization, both were rediscovered inside Lean's own elaborator, the object language moved into its host and its safety became theorems, and at the end the evaluator turned out to have been an abstract machine folded up. No chapter invented its subject; each uncovered structure the previous ones already contained.
Further reading
- Ager, Biernacki, Danvy, and Midtgaard, A Functional Correspondence between Evaluators and Abstract Machines: this chapter's derivation in full generality, with the CEK identification and the same walk continued to other evaluation orders and machines.
- Reynolds, Definitional Interpreters for Higher-Order Programming Languages: the 1972 origin of defunctionalization and of the study of interpreters as definitions, reprinted in Higher-Order and Symbolic Computation.
- Danvy and Nielsen, Defunctionalization at Work: the technique studied on its own terms, crediting the continuation example to Reynolds and collecting many more.
- Hutton, Programming language semantics: it's easy as 1,2,3 (JFP 2023): a tutorial that walks denotational, small-step, and big-step semantics on the language of integers and addition, then derives an abstract machine by continuations and defunctionalization, this chapter's derivation in miniature.
- Appel, Compiling with Continuations (1992): what happens when a compiler commits to this chapter's first transformation wholesale, continuation-passing style as the entire intermediate language of a production ML compiler.
- Might, How to compile with continuations: the CPS rewrite this chapter performed by hand on one evaluator, given as an algorithm that transforms any program, from the naive translation to the higher-order and hybrid ones.