all_lessons/SICP JavaScript/16 · Lazy evaluationlesson 16 / 22

Lazy evaluation

Lesson 15 split the evaluator into analyze (done once) and execute (done many times) and ended on a claim that sounds almost dangerous: once you own the evaluator, you own the semantics. Change one rule and the language changes underneath every program. This lesson cashes that claim for the first time. We take the exact same evaluator from Lesson 14 and change one thing — when an argument is evaluated — and a different programming model falls out: normal-order (lazy) evaluation. Programs that diverged now terminate; the hand-built streams of Lesson 13 reappear as ordinary pairs, for free.

Book source
Maps to SICP 4.2 (variations on a scheme — lazy evaluation): normal vs applicative order, thunks and forcing, memoizing thunks, and lazy pairs/streams. We follow that arc — define both orders precisely, exhibit a divergence difference, retrofit apply with delayed arguments, then recover streams as plain pairs — but the evaluator code, the divergence example, and the traces are original JavaScript.
Linear position
Prerequisite: Lesson 14 (the metacircular evaluate/apply cycle), Lesson 15 (analysis separated from execution — you can change the evaluator without changing programs), and Lesson 13 (streams as pairs with a delayed tail).
New capability: a precise definition of applicative vs normal order; the ability to change argument-passing in the evaluator to get lazy evaluation; and the realization that delayed evaluation, once it lives in the evaluator, makes streams an ordinary use of pairs rather than a special construct.
The plan
Five moves. (1) Define the two evaluation orders precisely as two rules for apply. (2) Exhibit one expression that diverges under one order and terminates under the other — the whole point in one program. (3) Implement laziness as a small surgery on apply: thunks, forcing, and the one-character difference memoization makes. (4) Watch lazy pairs and infinite lists fall out for free, with no delay machinery. (5) Pay the bill — what reasoning gets harder when you can no longer see when code runs.

1 · Two orders, defined precisely

Every evaluator for a call f(arg1, arg2, …) must answer one question: do you evaluate the argument expressions before entering the body, or only when the body actually needs their values? There are two disciplined answers.

Applicative order (eager)
Evaluate every argument expression to a value first, then enter the body with those values bound. This is what Lesson 14's apply does, and what stock JavaScript does. "Evaluate the operands, then apply."
Normal order (lazy)
Enter the body immediately, binding each parameter to the unevaluated argument expression. An argument is evaluated only when — and each time — the body actually uses it. "Fully expand, then reduce."

The names come from the lambda calculus: normal order reduces the outermost (leftmost) application first; applicative order reduces the arguments (the inner redexes) first. The key theorem behind this lesson: if any reduction order terminates with a value, normal order does too. Applicative order can loop forever on expressions normal order would finish — never the reverse. So normal order strictly terminates more often; the cost is that an argument used twice may be computed twice, and that effects fire at surprising moments. The next sections make both halves of that trade concrete.

2 · An expression that diverges one way and terminates the other

Define a function that ignores its second argument, and call it with a second argument that loops forever:

function loop() { return loop(); }          // never returns
function constant(x, y) { return x; }        // ignores y entirely

constant(42, loop());
Worked example — same call, two fates
APPLICATIVE ORDER (eager — stock JS, Lesson 14's apply)
  evaluate constant(42, loop())
   -> evaluate operands first:  42 -> 42,  loop() -> evaluate loop()
        -> loop() -> loop() -> loop() -> ...        DIVERGES
   the body is never entered; the answer 42 is never reached.

NORMAL ORDER (lazy)
  evaluate constant(42, loop())
   -> enter body immediately, bind  x := <thunk 42>,  y := <thunk loop()>
   -> body is  return x;   it forces x -> 42
   -> y is never forced, so loop() is never run.        RETURNS 42
Same source text, same evaluator structure, one changed rule about arguments — and the difference is total: one run never halts, the other returns immediately. This is "owning the semantics" made visible. Note normal order did not make a wrong program right; it refused to compute something the answer never depended on.

This is also the precise reason the substitution model in Lesson 01 quietly assumed eager order: it reduced f(arg) by first reducing arg. Normal order is the other legal substitution discipline, and it is the one that matches lambda-calculus "fully expand, then reduce."

3 · Laziness as surgery on apply

We do not write a new evaluator. We take Lesson 14's and change how arguments cross the boundary into a function body. A thunk is the device: a delayed computation — the unevaluated argument expression packaged with the environment it must be evaluated in. Forcing a thunk means finally evaluating it to a real value.

// A thunk: expression + environment, not yet evaluated, plus a memo slot.
function makeThunk(expr, env) {
  return { thunk: true, expr, env, evaluated: false, value: undefined };
}

// force: turn a thunk into a real value, recursively (a thunk may force to a thunk).
function force(obj) {
  if (!isThunk(obj)) return obj;                 // already a value
  if (obj.evaluated) return obj.value;           // MEMO: computed before? reuse it
  const result = force(evaluate(obj.expr, obj.env));
  obj.evaluated = true;                          // <-- the one change that makes it
  obj.value = result;                            //     a MEMOIZING (call-by-need) thunk
  return result;
}

Now the two surgical edits to the eval/apply cycle. First, apply must not pre-evaluate operands — it binds each parameter to a thunk. Second, the few places that genuinely need a real value (a primitive operation, the predicate of a conditional, the final answer to print) must force first.

// In evaluate(): an application no longer evaluates its operands.
function evalApplication(expr, env) {
  const fn = force(evaluate(expr.operator, env));        // operator: need a real fn -> force
  const args = expr.operands.map(o => makeThunk(o, env)); // operands: DELAY them, don't run
  return apply(fn, args);
}

// apply binds parameters to thunks; primitives force, compound bodies don't.
function apply(fn, args) {
  if (isPrimitive(fn)) return fn.impl(...args.map(force));   // primitives need values
  const frame = extendEnv(fn.params, args, fn.env);         // bind params to THUNKS
  return evalSequence(fn.body, frame);
}

// conditionals must force the predicate (you cannot branch on a thunk):
function evalIf(expr, env) {
  return isTruthy(force(evaluate(expr.predicate, env)))
       ? evaluate(expr.consequent, env)
       : evaluate(expr.alternative, env);
}
DisciplineRuleRe-evaluates a reused argument?
call-by-valueforce every arg before entering the body (Lesson 14)n/a — already a value
call-by-namethunk every arg; force on each use; no memo slotYes — once per use
call-by-need (lazy)thunk every arg; force on each use; memoize the resultNo — computed once, cached

The two lazy disciplines differ only by the four lines that set evaluated/value. Drop the memo slot and a reused argument is recomputed on every use (call-by-name); keep it and the result is cached (call-by-need). For pure programs both give the same answers; with effects they differ, which is exactly why the cost in §5 bites.

4 · Streams fall out for free — as ordinary pairs

Recall the labor of Lesson 13: to get an infinite list we hand-built a pair whose tail was wrapped in an explicit delay and unwrapped with force, and we needed special stream_map/stream_filter that knew about the wrapping. In a lazy evaluator that entire apparatus vanishes, because every argument is already delayed. An ordinary pair built by an ordinary constructor already has a delayed tail.

// Plain pairs in the LAZY language — no delay/force anywhere in sight:
function pair(a, b) { return f => f(a, b); }   // ordinary pair from Lesson 04
const head = p => p((a, b) => a);
const tail = p => p((a, b) => b);

// An INFINITE list of integers — an ordinary recursive definition:
function integersFrom(n) { return pair(n, integersFrom(n + 1)); }
const integers = integersFrom(1);

head(integers);              // 1     (forces only the head)
head(tail(integers));        // 2     (the tail thunk fires only now)
head(tail(tail(integers)));  // 3

Why does integersFrom(n + 1) not loop forever? Because in the lazy language pair is just a function call, so its second argument is thunked, not run. The recursion unrolls exactly one step per tail you actually take. The Lesson 13 stream was a local patch — delay this one tail by hand. Lazy evaluation is the same idea lifted into the evaluator: delay every argument, always. Same generality, none of the bespoke combinators.

Why this is the deep payoff
Lesson 13 had to invent a parallel universe of stream operators because only the tail was delayed. Here map, filter, append written for finite lists work unchanged on infinite ones, because laziness is a property of the evaluator, not of a special data type. The data abstraction (pairs) and the evaluation strategy (laziness) have been cleanly separated — which is the whole metalinguistic point.

5 · The bill: you can no longer see when things run

Laziness is not free lunch. The eager evaluator gave you a reliable mental model: arguments run left to right, before the call, exactly once. Lazy evaluation shatters that, and the casualties are mostly about order and effects.

Effects fire at unpredictable times
A display(x) passed as an argument runs whenever x is first forced — which might be deep inside another function, or never. Reasoning about output order (the Lesson 09 problem) gets much harder; this is why lazy languages quarantine effects.
Space leaks
An unforced thunk pins its whole environment alive. A long chain of unforced tails can hold gigabytes of suspended work — a class of leak that simply does not exist in eager code.
call-by-name vs call-by-need diverge under effects
Without the memo slot, an argument with a side effect fires once per use; with it, once total. The "harmless" memoization optimization changes observable behavior the instant arguments are impure.
Debugging is non-local
An exception thrown by an argument surfaces at the force site, not the call site, so the stack trace points at the consumer, not the producer of the bad value.

Common mistakes / failure modes

Forgetting to force the predicate
If evalIf branches on a raw thunk it is always truthy (an object), so every conditional takes the consequent. Predicates, primitive operands, and the printed answer are the three places you must force.
"Lazy means faster"
No. Laziness can be slower (thunk allocation, repeated forcing without memo) or avoid work entirely. It changes termination and what gets computed, not raw speed.
Thinking pairs need special stream ops
Under lazy evaluation, importing Lesson 13's stream_map is redundant — ordinary map already streams. The special operators are a symptom of a non-lazy host.
Confusing laziness with the host's &&
JavaScript already lazily evaluates the right side of && and the branches of ?: — those are special forms. Lazy evaluation makes ordinary function arguments behave that way uniformly.

Checkpoint exercise

Try it
Using the lazy evaluator above, answer each:
// a
function unless(cond, usual, exceptional) { return cond ? exceptional : usual; }
unless(false, 1, divideByZero());   // does divideByZero() run?

// b  (memo on)  vs  (memo off)
function twice(x) { return x + x; }
twice(sideEffectReturning7());      // how many times does the side effect fire?

// c
function ones() { return pair(1, ones()); }   // does defining `ones` loop?
head(tail(tail(ones())));                       // what does this return?
Answers: a No — cond is false, so the body returns usual (=1); exceptional is never forced, so divideByZero() never runs. (This is exactly the kind of control structure you cannot write as an ordinary function in an eager language.) b With memoization (call-by-need): once — first use of x forces and caches 7, second use reads the cache. Without it (call-by-name): twice, since x appears twice. c No, defining ones does not loop — the recursive call sits in a thunked argument. The expression returns 1 (forcing two tails then one head).

Where this points next

We changed one rule — when arguments evaluate — and got a new language. That proves the method, so now we ask: what other single rule, changed, buys the most power? Lazy evaluation still computes one answer per expression. Lesson 17 changes the evaluator so an expression may legitimately yield many answers: we add amb, a choice operator, and the evaluator gains automatic backtracking. Where laziness asked "evaluate this argument later," nondeterminism asks "evaluate this expression every possible way, and back up when a constraint fails." The mechanism is a second pair of continuations threaded through the evaluator — and it turns search from a thing you hand-code with loops (or Lesson 13 streams) into a primitive of the language itself.

Takeaway
Applicative order evaluates every argument to a value before entering a function body; normal order enters the body immediately and evaluates an argument only when the body needs it. The theorem that matters: if any order terminates, normal order does — so lazy evaluation terminates strictly more often, at the cost of recomputation and unpredictable effect timing. We obtained it not by writing a new evaluator but by surgery on Lesson 14's apply: bind parameters to thunks (expression + environment), force them only where a real value is needed (predicates, primitives, the printed answer), and memoize the forced result to get call-by-need rather than call-by-name. The dividend is that Lesson 13's hand-built delayed streams collapse into ordinary pairs: integersFrom(n) = pair(n, integersFrom(n+1)) is a working infinite list because the tail is a thunked argument. The cost is that you can no longer read off when code runs — effects fire at force time, thunks leak space, and call-by-name/need diverge under impurity. Owning the evaluator means owning the semantics: one rule, one new model.

Interview prompts