Evaluation and Reduction of Expressions
Consider the expression `(3 + 4) (2 + let x = 5 in x x)`. A human reads this instantly. But a machine must be told, precisely, in what order to combine the pieces. It must also be told what an intermediate result even means. This is the problem of expression evaluation: turning a syntactic object — a tree of operators and operands — into a value. This process consults an environment for the meanings of names. Every interpreter, every compiler's constant folder, and every type checker's semantic model rests on a formal answer to this question. The idea matters because "the same" expression can be evaluated in more than one legitimate way. The choice affects termination, sharing, and side effects. Reduction is not a single algorithm but a discipline. It is a set of rules that says which sub-expression may be simplified next. It also says what a fully simplified expression looks like. Getting this discipline precise matters for language designers. It lets them prove that a program has at most one meaning (determinism). It lets them prove that evaluation of a well-typed program never gets "stuck" (progress). It also ensures optimizations like constant folding or common-subexpression elimination preserve meaning. The big picture is a rewriting system. An expression is a tree. Evaluation repeatedly rewrites the tree, replacing a reducible sub-expression (a redex) with a simpler one. This process is guided by an environment mapping variables to values. It continues until no redex remains. This report of "no redex remains" is a value — the irreducible, final form. This is exactly the small-step operational semantics used to define languages formally. It is exactly what an interpreter's `eval` function computes in one shot (big-step style). This article builds the machinery from the ground up. It covers the syntax of expressions and the environment they are evaluated against. It covers the two standard styles of semantics (big-step and small-step). It covers the recursive `eval` algorithm. It also covers the invariants (progress, determinism, termination behavior) that make the whole framework trustworthy. It closes by connecting reduction order to the strategies — call-by-value, call-by-name, call-by-need — that later articles treat in depth.
- An expression evaluates to a value relative to an environment binding free variables to values.
- Big-step semantics (`e ⇓ v` under `ρ`) defines the final value directly; small-step semantics (`e → e'`) defines one reduction at a time.
- A redex is a reducible sub-expression; evaluation repeatedly contracts redexes until an irreducible normal form (value) is reached.
- The recursive `eval` algorithm walks the abstract syntax tree, looking up variables in the environment and combining sub-results structurally.
- Determinism (unique value) and progress (well-formed non-values always reduce) are the correctness properties every sound evaluator must satisfy.
- The order in which redexes are chosen — leftmost, innermost, outermost — is exactly what later separates call-by-value from call-by-name/need.
Evaluating an expression is repeatedly rewriting the smallest reducible piece, guided by an environment, until nothing more can be simplified — and a well-designed language guarantees this process, if it stops, always stops at the same answer.
The idea
An expression is a syntactic term built from literals, variables, operators, and constructs like conditionals, `let`-bindings, and function applications. Formally, expressions form a tree — the abstract syntax tree (AST) — and evaluation is a function from (expression, environment) pairs to values. An environment `ρ` (rho) is a partial function from variable names to values, typically implemented as a chain of frames (see Environments); it supplies meanings for the free variables that occur inside the expression being evaluated. A value is a piece of syntax (or a semantic object) that requires no further evaluation: an integer literal like `7`, a boolean `true`, a closure representing a function, or a constructed data value. Crucially, values are a subset of expressions — every value is trivially "already evaluated." The evaluation relation therefore partitions all well-formed expressions into values (already done) and non-values (which contain at least one redex, a reducible sub-expression such as `2 + 3` or `(λx. e) v`, that some rule tells us how to simplify). There are two standard, and provably equivalent, styles for specifying this process. Big-step (natural) semantics writes judgments of the form ``` ρ ⊢ e ⇓ v ``` read "under environment `ρ`, expression `e` evaluates to value `v`," and defines this judgment by structural rules that go straight from an expression to its final value — exactly how a recursive `eval` function is coded. Small-step (structural operational) semantics instead writes ``` e / ρ → e' / ρ' ``` read "one step of computation rewrites `e` (with environment `ρ`) to `e'` (with possibly updated `ρ'`)," and defines evaluation as the reflexive-transitive closure `→*` of this single-step relation, terminating when `e'` is a value. Small-step semantics exposes intermediate configurations — essential for reasoning about non-termination, concurrency, and reduction order — while big-step semantics is closer to an implementer's intuition and more concise for languages without those subtleties. Both are used throughout the study of Expressions and Statements and both agree on the final value whenever evaluation terminates deterministically.
How it works
The syntax being evaluated
A minimal expression language, enough to illustrate every mechanism, has this grammar: ``` e ::= n -- integer literal | x -- variable | e1 op e2 -- binary operator (+, -, *, ==, <, ...) | if e1 then e2 else e3 -- conditional | let x = e1 in e2 -- local binding | λx. e -- function (lambda abstraction) | e1 e2 -- application ``` Values `v` are the literals `n`, booleans, and closures `⟨λx. e, ρ⟩` — a function paired with the environment in which it was created (this pairing is what makes Environments essential to functions, not just to variable lookup).
Big-step evaluation rules
The judgment `ρ ⊢ e ⇓ v` is defined inductively on the structure of `e`: ``` [Lit] ρ ⊢ n ⇓ n [Var] ρ(x) = v ───────────── ρ ⊢ x ⇓ v [Op] ρ ⊢ e1 ⇓ n1 ρ ⊢ e2 ⇓ n2 n = n1 op n2 ────────────────────────────────────────── ρ ⊢ e1 op e2 ⇓ n [If-T] ρ ⊢ e1 ⇓ true ρ ⊢ e2 ⇓ v ────────────────────────────── ρ ⊢ if e1 then e2 else e3 ⇓ v [If-F] ρ ⊢ e1 ⇓ false ρ ⊢ e3 ⇓ v ────────────────────────────── ρ ⊢ if e1 then e2 else e3 ⇓ v [Let] ρ ⊢ e1 ⇓ v1 ρ[x ↦ v1] ⊢ e2 ⇓ v2 ───────────────────────────────────── ρ ⊢ let x = e1 in e2 ⇓ v2 [Lam] ρ ⊢ λx. e ⇓ ⟨λx. e, ρ⟩ [App] ρ ⊢ e1 ⇓ ⟨λx. e, ρ'⟩ ρ ⊢ e2 ⇓ v2 ρ'[x ↦ v2] ⊢ e ⇓ v ─────────────────────────────────────────────────────────── ρ ⊢ e1 e2 ⇓ v ``` Each rule has premises above the line and a conclusion below; a derivation is a finite tree built from these rules that proves a specific evaluation. Note `[App]` evaluates the function's body in the closure's environment `ρ'` extended with the argument, not in the caller's environment — this is exactly the lexical scoping discipline that gives environments their well-defined meaning.
Small-step reduction rules
The same language, one step at a time, using evaluation contexts `E` to pin down exactly where the next redex is: ``` E ::= [] | E op e | v op E | if E then e2 else e3 | let x = E in e2 | E e2 | v E ``` A single global rule plugs a redex into a context: ``` [Ctx] e → e' ──────────────── E[e] → E[e'] ``` and the base reduction rules contract the innermost redex found by the grammar of `E`: ``` n1 op n2 → n where n = n1 op n2 if true then e2 else e3 → e2 if false then e2 else e3 → e3 let x = v in e2 → e2[x ↦ v] (or: extend ρ, see below) (λx. e) v → e[x ↦ v] (β-reduction, substitution form) ``` In the environment-passing formulation (matching the big-step rules above), substitution `e[x ↦ v]` is replaced by environment extension `ρ[x ↦ v]`, avoiding the cost of textual substitution — this is the standard implementation choice and is discussed further as the mechanism behind Evaluation Strategies: Call-by-Value, Name, and Need.
The `eval` algorithm
Here is the big-step rules turned directly into a recursive interpreter — this is the algorithm real interpreters run: ``` function eval(e, ρ): match e: case Lit(n): return n case Var(x): if x in ρ: return ρ[x] else: error "unbound variable: x" case BinOp(op, e1, e2): v1 = eval(e1, ρ) v2 = eval(e2, ρ) if not (isNumber(v1) and isNumber(v2)): error "type error in operator" return applyOp(op, v1, v2) case If(e1, e2, e3): v1 = eval(e1, ρ) if v1 == true: return eval(e2, ρ) else if v1 == false: return eval(e3, ρ) else: error "condition is not boolean" case Let(x, e1, e2): v1 = eval(e1, ρ) ρ' = extend(ρ, x, v1) return eval(e2, ρ') case Lambda(x, body): return Closure(x, body, ρ) -- captures ρ NOW case App(e1, e2): f = eval(e1, ρ) a = eval(e2, ρ) if not isClosure(f): error "applying a non-function" (x, body, ρcap) = unpackClosure(f) ρ'' = extend(ρcap, x, a) return eval(body, ρ'') -- body runs in CAPTURED env ``` Base cases are `Lit` and the lookup branch of `Var`; every other case is defined by recursive calls to `eval` on strictly smaller sub-expressions, guaranteeing termination of the algorithm on any finite AST, whatever the language's own halting behavior may be (a program can still loop forever via recursive functions — that non-termination lives inside the recursive call in `App`, not in the structural recursion of `eval` itself).
Reduction as a graph
The figure below shows one full big-step derivation for `let x = 5 in x * x + 3`, displayed as the recursive call tree that `eval` actually walks, alongside the environment frame it builds.
Complexity & correctness
Time complexity. For an expression AST with `n` nodes and an environment implemented as a hash map or association list, each `eval` case does O(1) work (a lookup, an arithmetic operation, an environment extension) besides its recursive calls. Since `eval` recurses exactly once per child edge of the AST and every node is visited at most once per evaluation path actually taken, evaluating a straight-line expression (no functions, no loops) costs O(n) time and O(d) auxiliary stack space, where `d` is the AST's depth (from the call stack of recursive `eval` invocations). If the environment is a linked chain of frames rather than a flat array, variable lookup costs O(k) where `k` is the lexical nesting depth to the binding site, adding at most an O(k) factor per `Var` node — still linear overall for programs with bounded nesting, and this is the standard reason production interpreters prefer array-indexed "de Bruijn" environments over chains once performance matters. For a language with recursive functions (`App` calling into user-defined recursive closures), there is no finite bound on the number of `eval` invocations — the interpreter's recursion can outlive the program's static AST size, exactly mirroring the fact that the source language itself need not terminate. This is not a flaw in the algorithm; it is the expected, unavoidable consequence of Turing-completeness (no algorithm can decide termination in general — the halting problem), and the evaluator's job is only to compute the right answer when the program terminates.
Space complexity. Big-step evaluation as written is a direct recursive-descent interpreter, so its space cost is the call-stack depth, O(d) for straight-line code and O(recursion depth) for recursive programs — the same asymptotic shape as any recursive function evaluator, and the reason deeply recursive, non-tail-recursive source programs can overflow the interpreter's native stack even though the source language has no explicit stack notion.
Correctness. Three properties are what "correct" means for an evaluator, and each has a clean argument: 1. Determinism. For every expression `e` and environment `ρ`, there is at most one `v` with `ρ ⊢ e ⇓ v`. Proof sketch: induction on the structure of the big-step derivation. Every rule's conclusion is syntactically keyed to one AST constructor (`[Lit]` only concludes about literals, `[Op]` only about binary operators, etc.), so at most one rule can apply to any given `e`; each rule's premises are themselves unique evaluations of strictly smaller sub-expressions, which are unique by the induction hypothesis. Hence the whole derivation, and its resulting value, is unique. 2. Progress (for a typed language). If `e` is well-typed and is not already a value, then some evaluation rule applies to it — evaluation never gets "stuck" on a syntactically valid, type-correct program. This is proved by case analysis on typing rules matched against the corresponding evaluation rules (e.g., a well-typed `e1 op e2` guarantees `e1` and `e2` evaluate to numbers, so `[Op]`'s side condition is satisfiable). Progress together with preservation (evaluation preserves well-typedness) constitute type soundness, the guarantee that "well-typed programs don't go wrong." 3. Big-step / small-step agreement. `ρ ⊢ e ⇓ v` holds if and only if `e/ρ → v` (the small-step relation reaches the value `v` in finitely many steps). Proof sketch*: by induction on the big-step derivation for the forward direction (each big-step rule is simulated by a finite, non-empty sequence of small-step reductions built from its premises' step sequences), and by induction on the length of the small-step reduction sequence for the reverse direction. This equivalence is what licenses using whichever style is more convenient for a given proof or implementation, and it underlies proving compiler transformations (constant folding, inlining) correct: showing a rewritten expression small-step-reduces to the same value as the original, or equivalently big-step-evaluates to it, is exactly a semantics-preservation proof.
Tradeoffs. Big-step semantics is compact and matches interpreter code directly, but it cannot express or reason about non-terminating computations (there is no derivation for a diverging program — that absence is itself the fact you must reason about separately), nor easily model interleaving/concurrency. Small-step semantics handles both gracefully (a diverging program is one with an infinite `→` chain, directly observable) but is more verbose to state and prove properties about, since every intermediate configuration must be tracked. Choosing environment-extension over substitution avoids the cost and hygiene hazards (variable capture) of textual substitution, at the price of needing an explicit closure representation for functions.
Worked example
Evaluate ``` let x = 2 + 3 in if x < 10 then x * x else 0 ``` under the empty environment `ρ₀ = {}`, tracing both the big-step derivation and, in parallel, the corresponding small-step reduction sequence.
| Step | Big-step subgoal | Small-step configuration |
|---|---|---|
| 0 | `ρ₀ ⊢ let x=2+3 in if x<10 then x*x else 0 ⇓ ?` | `let x = 2+3 in if x<10 then x*x else 0` |
| 1 | need `ρ₀ ⊢ 2+3 ⇓ ?` | context picks `2+3` (leftmost, innermost redex) |
| 2 | `[Op]`: `ρ₀⊢2⇓2`, `ρ₀⊢3⇓3`, so `ρ₀⊢2+3⇓5` | `→ let x = 5 in if x<10 then x*x else 0` |
| 3 | `[Let]` extends: `ρ₁ = {x↦5}` | (environment now carries `x↦5`) |
| 4 | need `ρ₁ ⊢ if x<10 then x*x else 0 ⇓ ?` | context picks the condition `x<10` |
| 5 | need `ρ₁ ⊢ x<10 ⇓ ?`; `ρ₁(x)=5`, so `ρ₁⊢x<10⇓true` | `→ if 5<10 then xx else 0` then `→ if true then xx else 0` |
| 6 | `[If-T]`: evaluate `ρ₁ ⊢ x*x ⇓ ?` | `→ x*x` (with `x↦5` in scope) |
| 7 | `ρ₁(x)=5` twice, `[Op]`: `5*5=25` | `→ 5*5 → 25` |
| 8 | Result: `ρ₀ ⊢ let ... ⇓ 25` | Result: `→* 25` |
Both columns agree on the final value `25`, illustrating the big-step/small-step equivalence proved above; the small-step column additionally exposes the intermediate configurations `if 5<10 then xx else 0` that the big-step derivation never writes down explicitly — exactly the extra granularity small-step semantics is built to expose. A second, trickier case shows why environment discipline, not just arithmetic, is what evaluation is really testing: ``` let x = 10 in let f = λy. x + y in let x = 20 in f 5 ``` Tracing `[Lam]`: when `f` is created, its closure captures the environment <strong>at that point</strong>, `{x↦10}` — call this `ρ_f`. The later `let x = 20` shadows `x` in the outer scope but does <strong>not</strong> touch `ρ_f`, because closures freeze their creation environment. When `f 5` is applied, `[App]` evaluates `f`'s body `x + y` in `ρ_f[y↦5] = {x↦10, y↦5}`, giving `10 + 5 = 15`, <strong>not</strong> `20 + 5 = 25`. This is the formal justification for lexical (static) scoping: rule `[App]`'s premise `ρ' ⊢ e ⇓ v` — using the closure's own `ρ'`, not the call site's `ρ` — is precisely the mechanism, and getting this rule wrong (using the caller's* environment instead) is exactly what produces dynamic scoping, a different and less common discipline.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Using the call site's environment instead of the closure's captured one in `[App]` | Conflating "where a function is called" with "where it was defined" | Always store `ρ` inside the closure at creation (`[Lam]`) and re-enter that `ρ` in `[App]` |
| Forgetting to extend, not mutate, the environment in `[Let]`/`[App]` | Treating `ρ` as a single global table instead of an immutable/versioned chain | Model `extend(ρ,x,v)` as producing a new frame; never overwrite bindings visible to other scopes |
| Assuming `eval` always terminates | Structural recursion on the AST is finite, but `App` can re-enter `eval` on a recursive closure's body indefinitely | Accept that termination is a property of the source program, not of the evaluator; document this explicitly |
| Substitution-based small-step rules capturing a variable | Naive text substitution `e[x↦v]` can accidentally bind a free variable in `v` if names collide | Use environment-passing (as above) or alpha-rename before substituting |
| Treating "value" and "irreducible expression" as different things | Values are literally the base case of the expression grammar, easy to forget when adding new constructs | When adding a new AST node, immediately decide: is it a value, or does it need a reduction rule? |
| Evaluating both branches of `if` "just in case" | Overzealous eager evaluation of an interpreter written without matching the semantics rules | Only evaluate the taken branch — `[If-T]`/`[If-F]` each mention exactly one of `e2`, `e3` |
Pioneers & history
The formal machinery of expression evaluation — substitution, reduction, normal forms — descends directly from mathematical logic's study of computation, decades before electronic computers existed, and was later reshaped into the operational-semantics style used throughout language design.
Origins · 1 minAlonzo Church and how this was discovered
Where this matters
Every interpreter for every scripting or dynamic language — Python's CPython evaluator, JavaScript engines' bytecode interpreters before JIT compilation, Scheme and Lisp REPLs — implements some version of the recursive `eval` function shown here, walking an AST or its intermediate representation against an environment (or, at machine level, a stack frame and closure structure playing the same role). Compilers rely on the same reduction rules to justify constant folding (rewriting `2+3` to `5` at compile time is only sound because the small-step rule for `+` proves the two are semantically identical) and inlining (substituting a function call by its body, justified by β-reduction). Formal verification tools and proof assistants (Coq, Isabelle) encode exactly these big-step or small-step judgments to prove compiler correctness theorems, most famously in the CompCert verified C compiler. Database query planners and spreadsheet engines apply the same "reduce until no redex remains" discipline to formula evaluation. Understanding evaluation as controlled rewriting under an environment is the direct prerequisite for Evaluation Strategies: Call-by-Value, Name, and Need, which studies which redex a language chooses to contract first, and for The Functional Paradigm, which elevates expression reduction — rather than statement execution — into the entire programming model.
:::quiz[In the big-step rule `[App]`, the function's body is evaluated in which environment?]
- The environment at the call site
- A brand-new empty environment
- The environment captured inside the closure, extended with the argument ✓
- The global environment only
Ask the tutor: How does the choice between call-by-value and call-by-name change which redex the evaluator picks first, and why can that change whether a program terminates?
What exactly is the difference between an expression and a value?
An expression is any syntactically valid term in the language's grammar — it may contain operators, variable references, or function applications still to be resolved. A value is the special subset of expressions that require no further evaluation: literals, booleans, and closures are typical examples. Every value is trivially an expression (the base case of the recursion), but not every expression is a value. Evaluation's entire job is to map an arbitrary expression to the unique value it denotes, given an environment supplying meanings for its free variables. This distinction matters because evaluation rules are defined by case analysis on whether a sub-expression is already a value (then use it directly) or still needs reducing (then recurse).
Why do closures need to store an environment at all?
A function body can mention free variables — names not bound by its own parameter list — and those names must resolve to whatever was in scope where the function was written, not where it happens to be called. Storing the defining environment inside the closure, and re-entering exactly that environment (extended with the argument) when the function is applied, is what implements lexical scoping. Without this, a function could see different bindings for the same free variable depending on caller context, breaking the basic promise that a variable's meaning is determined by where it is textually written, which is essential for reasoning about and composing code reliably.
Is big-step or small-step semantics "more correct"?
Neither — they are proven equivalent whenever a value is reached, so they describe the same evaluation relation from two complementary angles. Big-step semantics is more convenient for writing interpreters directly and for straightforward correctness proofs about terminating programs. Small-step semantics is indispensable when you need to talk about intermediate states: modeling non-termination, interleaving concurrent computations, or proving properties like type soundness that require reasoning about programs mid-execution. Language specifications often present both, using small-step semantics for the formal theorems and big-step intuition for implementers.
Does the order of evaluation ever change the final value?
For the pure, effect-free core language shown here, no — the Church-Rosser property guarantees that if two different reduction orders both terminate, they reach the same normal form (value). However, evaluation order absolutely matters for termination itself (one order might loop forever while another finds a value on an expression with a redex that is never needed) and for programs with side effects, where the order of printing, mutation, or exceptions is observable even if the final numeric result would coincide. This exact tension motivates the separate, deeper study of call-by-value versus call-by-name/need evaluation strategies.
What happens if a variable is not found in the environment?
This is a runtime (or, in statically scoped, statically checked languages, a compile-time) error: the `[Var]` rule has no applicable case, so no derivation exists, meaning the expression has no defined value under that environment. Well-designed language implementations catch this earlier via static scope-checking, rejecting programs with unbound variables before evaluation ever begins, precisely so that the "stuck" case (an expression matching no evaluation rule) never arises for programs accepted by the compiler — this is one instance of the progress property discussed under correctness.
Why does `let x = e1 in e2` need to extend the environment rather than substitute e1 directly into e2?
Substitution `e2[x ↦ e1]` (textually replacing `x` with the expression `e1`) is mathematically valid but operationally wasteful and hazard-prone: it can re-evaluate `e1` multiple times if `x` occurs many times in `e2`, and naive textual substitution risks variable capture if `e1` contains free variables that collide with names bound inside `e2`. Environment extension evaluates `e1` exactly once to a value `v1`, stores the binding `x ↦ v1` in a fresh frame, and lets subsequent lookups of `x` retrieve `v1` directly — sharing the single computed value safely and efficiently, and avoiding capture entirely because bindings are looked up by structural scope, not by text matching.
How does this connect to compiler optimizations like constant folding?
Constant folding rewrites a sub-expression such as `2 + 3` to `5` at compile time instead of at runtime. This transformation is only sound because the small-step rule for `+` proves that `2 + 3` and `5` reduce to (or are) the exact same value under every environment — the rewrite preserves the evaluation relation entirely. More generally, every legitimate compiler optimization on expressions is a semantics-preservation proof phrased in exactly this reduction framework: showing the rewritten expression evaluates, under the same environment, to the same value as the original, for all possible environments and all possible executions.