← back to programming

Lazy Evaluation

Abelson & Sussman · 1996 · SICP §4.2

Normal-order evaluation delays arguments until needed. Thunks wrap unevaluated expressions with their environments. Memoization ensures each thunk is forced at most once.

thunk expression environment force value a thunk wraps an unevaluated expression with its environment

Normal order vs applicative order

Applicative order evaluates arguments before applying the procedure. Normal order passes arguments unevaluated and only forces them when actually needed. The results agree for pure functions, but diverge when evaluation has side effects or non-termination.

Scheme

An interpreter with lazy evaluation

To make evaluation lazy, we change apply: instead of evaluating arguments, we wrap them as thunks. A thunk is a pair of (expression, environment) — everything needed to evaluate the expression later. When a value is actually needed, we force the thunk.

Scheme

Thunks and memoization

Without memoization, forcing the same thunk twice evaluates the expression twice. A memoized thunk caches its result after the first force. This is the difference between call-by-name (no memo) and call-by-need (with memo). Haskell uses call-by-need throughout.

Scheme

Streams as lazy lists

With lazy evaluation built into the interpreter, streams become ordinary lists. cons delays its second argument automatically. No special cons-stream or delay/force needed — the language itself is lazy. This unifies lists and streams.

Scheme

Lazy evaluation and control flow

In a lazy language, if doesn't need to be a special form — it can be an ordinary procedure, since arguments aren't evaluated until needed. User-defined control structures become possible. This is why Haskell can define if-then-else as a regular function.

Scheme

Notation reference

Scheme Python Meaning
(delay expr)lambda: exprWrap as thunk (suspend evaluation)
(force thunk)thunk()Evaluate a thunk
(cons-stream a b)yield / generatorLazy pair (b is delayed)
call-by-namelambda wrapperRe-evaluate thunk each time
call-by-need@functools.cacheEvaluate thunk once, cache result
Neighbors

Other reading pages

Foundations (Wikipedia)

Translation notes

Python is strictly applicative-order, so laziness must be encoded explicitly via lambda wrappers or generators. Scheme's delay/force are macros that do the wrapping automatically. In a fully lazy evaluator (like Haskell), neither is needed — all arguments are thunks by default. The Python generator protocol (yield) provides a natural translation for streams but not for general lazy arguments.

Ready for the real thing? Read SICP §4.2.