all_lessons/SICP JavaScript/14 · Metacircular evaluatorlesson 14 / 22

The metacircular evaluator

Lesson 13 showed that delaying a single stream tail — changing when one expression gets evaluated — bought us back the modularity that assignment had cost. If controlling the evaluation of one tail is that powerful, the full power is to control all evaluation: to write the evaluator itself. This lesson builds an evaluator for a JavaScript-like language in JavaScript. The substitution model (01) and the environment model (10) were descriptions of how evaluation works; here that description becomes running code — two mutually recursive functions, evaluate and apply, that turn the model into a program you can step through.

Book source
Maps to SICP 4.1.1–4.1.4 — the metacircular evaluator: the eval/apply core, representing expressions as syntax, environment operations as code, and the read-eval-print driver loop. We follow that arc but the evaluator code, the dispatch table framing, and the end-to-end trace are original and written for a JS-style surface language.
Linear position
Prerequisite: Lesson 13 (delayed evaluation — changing when an expression runs), Lesson 10 (the environment model — frames, bindings, the enclosing-environment chain, functions that remember their defining environment), and Lesson 07 (data-directed dispatch through an operation/type table).
New capability: implement an evaluator for a JS-like language as the eval/apply cycleevaluate(expr, env) dispatches on syntactic type, apply(fn, args) builds a frame and evaluates the body — turning the environment model from a paper procedure into an executable program.
The plan
Six moves. (1) State the central insight: evaluation is a cycle between two functions. (2) Represent programs as syntax data and dispatch on it — Lesson 07's data-directed dispatch, now keyed on syntactic type. (3) Build evaluate. (4) Build apply, and distinguish primitive from compound functions. (5) Make environments from Lesson 10 into code. (6) Wrap it in a read-eval-print driver loop, and explain why the whole thing is called metacircular. Then trace one function call end to end.

1 · The central insight: eval and apply call each other

Strip every language down and evaluation is two questions that keep handing off to each other:

evaluate(expr, env)
"What is the value of this expression in this environment?" To answer for a function-application expression, it must first find the value of the function and the values of the arguments — then it asks apply.
apply(fn, args)
"Given a function value and already-evaluated argument values, what does calling it produce?" For a compound function it builds a new frame binding parameters to arguments — then asks evaluate to run the body in that frame.

That mutual recursion is the engine. evaluate reduces compound expressions toward applications; apply reduces applications back into evaluating a body. Everything else — conditionals, names, literals — is bookkeeping around this cycle. The substitution model (01) was the human-readable sketch of it; the environment model (10) fixed it so state and closures work; this lesson makes that fixed model execute.

2 · Programs are syntax data — dispatch on type

To evaluate a program the evaluator must first read it as a data structure. We assume the source text has already been parsed into tagged objects — a tree where every node carries a type field naming its syntactic kind. This is exactly Lesson 06's symbolic data and Lesson 07's tagged data: a program is just another tree to walk.

// "x * x" parsed into syntax data (an abstract syntax tree node):
{ type: "application",
  fn:   { type: "name", name: "*" },
  args: [ { type: "name", name: "x" }, { type: "name", name: "x" } ] }

// a literal, a name, a conditional, a function definition:
{ type: "literal", value: 7 }
{ type: "name", name: "balance" }
{ type: "conditional", pred: P, then: A, else: B }
{ type: "lambda", params: ["x"], body: BODY }

Now recall Lesson 07's data-directed dispatch: instead of one giant conditional, you look the operation up in a table keyed by type. Here the very same move reappears, but the key is the syntactic type of the expression. evaluate is a dispatch on syntax. Say it plainly: data-directed dispatch from Lesson 07 returns as SYNTAX dispatch — the operation is "evaluate", the type is the kind of expression, and the table maps each syntactic form to the rule that evaluates it.

3 · evaluate — dispatch on the syntactic type

function evaluate(expr, env) {
  switch (expr.type) {
    case "literal":     return expr.value;                 // self-evaluating
    case "name":        return lookup(expr.name, env);     // env model rule 1
    case "lambda":                                          // make a compound fn
      return { kind: "compound", params: expr.params,
               body: expr.body, env: env };                // remembers defining env!
    case "conditional":                                     // evaluate predicate, branch
      return isTruthy(evaluate(expr.pred, env))
        ? evaluate(expr.then, env)
        : evaluate(expr.else, env);
    case "assignment":                                      // x = e
      assign(expr.name, evaluate(expr.value, env), env);
      return undefined;
    case "application": {                                    // the heart of the cycle
      const fn   = evaluate(expr.fn, env);                  // 1. evaluate the operator
      const args = expr.args.map(a => evaluate(a, env));    // 2. evaluate each operand
      return apply(fn, args);                               // 3. hand off to apply
    }
    default: throw new Error("unknown expression type: " + expr.type);
  }
}

Three things deserve emphasis. A literal evaluates to itself — the base case that stops the recursion. A name is looked up in the environment — this is rule 1 of the environment model (10), now a function call. A lambda evaluates to a compound-function value that captures the current env — this single line is what makes closures work: the function object remembers the environment it was born in, exactly as Lesson 10 demanded. And the application case is the cycle: evaluate the operator, evaluate the operands, then call apply.

4 · apply — make a frame, run the body

A function value is one of two kinds. A primitive function is one the evaluator does not implement itself — it borrows the host (real JavaScript) to do arithmetic, comparison, etc. A compound function is one defined by a lambda in the language being interpreted; apply runs its body.

function apply(fn, args) {
  if (fn.kind === "primitive") {
    return fn.impl(...args);                       // borrow the host: e.g. (a,b)=>a*b
  }
  if (fn.kind === "compound") {
    // environment model rule 2: extend the function's DEFINING env with a new frame
    const frame  = bindAll(fn.params, args);       // params -> argument values
    const newEnv = extend(frame, fn.env);          // parent = where the fn was defined
    return evaluateSequence(fn.body, newEnv);      // run the body in the new frame
  }
  throw new Error("not a function");
}

This is the second rule of the environment model made into code: applying a compound function builds a new frame binding parameters to argument values, whose enclosing environment is the function's defining environment (fn.env, captured at the lambda) — not the caller's. Then it evaluates the body there, which loops straight back into evaluate. The cycle closes.

Primitive functionCompound function
Defined bythe implementer (host JS)a lambda in the interpreted language
apply doescall fn.impl(...args)make a frame, evaluate the body
Example*, <, pairx => x * x
Touches the cycle?no — leaves the interpreteryes — re-enters evaluate

5 · Environments — Lesson 10's model, now as code

The environment is a chain of frames. A frame maps names to values; each frame points to its enclosing frame; the chain ends at the global frame, which holds the primitives. The two operations the evaluator needs are exactly the two rules from Lesson 10.

// a frame is a Map; an environment is { frame, parent } or null at the top
function extend(frame, parent) { return { frame, parent }; }
function bindAll(params, args) {
  const m = new Map();
  params.forEach((p, i) => m.set(p, args[i]));
  return m;
}
function lookup(name, env) {                         // rule 1: search the chain
  for (let e = env; e !== null; e = e.parent)
    if (e.frame.has(name)) return e.frame.get(name);
  throw new Error("unbound name: " + name);
}
function assign(name, value, env) {                  // find the binding, mutate it
  for (let e = env; e !== null; e = e.parent)
    if (e.frame.has(name)) { e.frame.set(name, value); return; }
  throw new Error("unbound name: " + name);
}

lookup walks outward through enclosing frames until it finds the name — that walk is lexical scoping. Because a compound function stored its defining environment, applying it always extends that chain, which is precisely why each bank account from Lesson 10 keeps a private, persistent balance: the closure's frame lives on the chain its lambda captured.

6 · The driver loop, and why "metacircular"

An interpreter is used through a read-eval-print loop (REPL): read a source expression, evaluate it in the global environment, print the result, repeat.

const globalEnv = extend(makeGlobalFrame(), null);   // holds the primitives
function repl() {
  while (true) {
    const expr  = parse(read());                     // text -> syntax data
    const value = evaluate(expr, globalEnv);          // run the cycle
    print(value);
  }
}

The evaluator is called metacircular because it implements a language using that same language's features — it uses the feature to implement the feature. Our evaluator implements function application by performing JavaScript function calls; it implements conditionals with a JavaScript if; it implements arithmetic by borrowing host arithmetic as primitives. It does not explain those mechanisms from scratch — it leans on the host. That is its beauty and, as we will see, its limitation: it is a crisp specification of meaning, not yet a self-sufficient implementation.

Worked example — evaluate + apply for one call, end to end
Suppose the global frame has square = x => x * x (a compound function whose env is the global env) and * bound to a primitive. We evaluate square(5), parsed as an application.
evaluate( square(5) , G )                       G = global env
  case "application":
    fn   = evaluate( name "square", G )         -> lookup -> {compound, params:[x], body:(x*x), env:G}
    args = [ evaluate( literal 5, G ) ]         -> [5]
    apply( square, [5] )
      kind == compound
        frame  = { x: 5 }
        newEnv = { frame:{x:5}, parent: G }      <-- new frame, parent is square.env = G
        evaluate( x * x , newEnv )               run body in the new frame
          case "application":
            fn   = evaluate( name "*", newEnv )  -> lookup walks to G -> {primitive, impl:(a,b)=>a*b}
            args = [ evaluate(name x,newEnv),    -> lookup x in newEnv.frame -> 5
                     evaluate(name x,newEnv) ]   -> 5
                 = [5, 5]
            apply( *, [5,5] )
              kind == primitive -> impl(5,5) -> 25     <-- leaves the cycle, host does it
          returns 25
        returns 25
      returns 25
  returns 25
Trace the handoffs: evaluate reached an application, called apply; apply built a frame and called evaluate on the body; that inner evaluate hit another application and called apply; the primitive apply bottomed out in the host and returned. The frame {x:5} exists only for this call — that is how the same square can be called again with a different argument and not clash.

Common mistakes / failure modes

Extending the caller's environment
In apply, the new frame's parent must be fn.env (where the lambda was defined), never the calling environment. Using the caller gives dynamic scope and breaks closures — the bank-account balance from Lesson 10 would leak. Lexical scope is "parent = defining env."
Forgetting to evaluate operands first
apply receives values, not expressions. The application case evaluates every operand before calling apply. (This eager step is exactly the rule Lesson 16 changes to get laziness.)
Confusing the two languages
There are two: the implemented language (the syntax data) and the implementing host (JS running the evaluator). A primitive crosses the boundary; everything else stays inside. Mixing them up is the classic metacircular confusion.
Treating a name like a literal
A name is not its spelling — it must be looked up in the environment. Returning expr.name as a string instead of lookup(expr.name, env) means nothing ever resolves to a value.

Checkpoint exercise

Try it
Using the evaluator above, hand-trace the evaluation of this program in the global environment (assume + and < are primitives):
const add1 = x => x + 1;     // a lambda bound in G
add1(add1(4));               // nested application
(a) How many times is apply called, and how many of those are compound vs primitive? (b) Draw the frame created by the outer add1 call — what is its parent? (c) Why can the inner and outer calls both bind x without interfering?

Answers: (a) apply is called 4 times: two compound (one per add1 call) and two primitive (one + per body). (b) The outer call's frame is {x: 5} (the inner add1(4) returned 5); its parent is add1.env = G, the environment where the lambda was defined — not the inner call's frame. (c) Each compound application makes a fresh frame, so there are two distinct x bindings on two distinct frames; lookup finds the one in the current call's frame first. This per-call frame is the whole reason recursion and reentrancy work.

Where this points next

The metacircular evaluator is a beautiful specification of meaning — but it is wasteful as an execution strategy. Look again at the trace: every time the body x * x runs, evaluate re-examines its syntactic type, re-discovers it is an application, re-dispatches on each operand. Put add1 in a loop that runs a million times and the evaluator re-traverses the same fixed syntax a million times, paying the dispatch cost on every iteration even though the shape of the code never changes. The syntax analysis and the actual work are tangled together in one pass. That is the precise pressure that forces Lesson 15: separate the analysis of an expression (done once) from its execution (done many times) — the analyzing evaluator.

Takeaway
An evaluator for a JS-like language, written in JS, is the eval/apply cycle: evaluate(expr, env) dispatches on the expression's syntactic type (Lesson 07's data-directed dispatch, now keyed on syntax) — literals self-evaluate, names are looked up, lambdas become compound-function values that capture their defining environment, and an application evaluates the operator and operands then calls apply. apply(fn, args) runs a primitive by borrowing the host, or runs a compound function by building a new frame (parent = the function's defining env, exactly the environment model of Lesson 10) and evaluating the body — which re-enters evaluate, closing the cycle. A read-eval-print driver loop reads syntax data, evaluates it in the global frame, and prints. It is metacircular because it implements each feature using that same feature in the host. Its weakness — re-traversing identical syntax on every run — is what motivates separating analysis from execution next.

Interview prompts