Evaluation Strategies

You call f(1 + 2, expensive()). Simple question with a surprisingly deep answer: when does 1 + 2 become 3, and when — if ever — does expensive() actually run? The rulebook a language follows to answer this is its evaluation strategy: the policy for when and how often a function's arguments get evaluated.

This is not hair-splitting. The same program, under different strategies, can crash or finish, do a piece of work zero times or a hundred times, print its effects in different orders. Three strategies dominate the landscape: call-by-value (evaluate first, then call — nearly every mainstream language), call-by-name (pass the raw expression, re-evaluate it at every use), and call-by-need (evaluate lazily, but at most once — the heart of Haskell and close kin to lazy evaluation).

The function that ignores its argument

Here is the sharpest possible test. A function that takes two arguments and simply throws the second away:

\text{constant}(x, y) = x

Now call it with something catastrophic as that second argument — a division by zero, an endless loop, an error that would tear the program down:

\text{constant}(42,\; 1 / 0)

Should the answer be 42, or should the program explode? It depends entirely on the evaluation strategy. Under call-by-value the machine insists on computing 1 / 0 before the call — so it blows up, even though the result is never used. Under call-by-name or call-by-need the argument is handed over unevaluated; since constant never touches y, the dangerous expression is never forced, and the answer is a calm 42.

Modelling the three strategies with thunks

We can feel the difference directly in TypeScript. A language that is call-by-value evaluates an argument to a plain value before the call. To simulate the lazy strategies we wrap the argument in a thunk — a zero-argument function () => ... — so the work only happens when the body forces it. We give the argument a visible side effect (a counter it bumps and logs) so we can literally count how many times it runs. Press Run:

let evals = 0; // The "expensive" argument: every evaluation bumps a counter and logs. function work(label: string): number { evals++; console.log(" evaluating " + label + " (eval #" + evals + ")"); return 10; } // A function that USES its argument twice. // Call-by-value: y is already a number, computed once before we got here. function useTwiceByValue(y: number): number { return y + y; } // Call-by-name / call-by-need: y is a THUNK we must force ourselves. function useTwiceByName(y: () => number): number { return y() + y(); // forces the thunk on EACH use } function useTwiceByNeed(y: () => number): number { let cached: number, done = false; const force = () => { if (!done) { cached = y(); done = true; } return cached; }; return force() + force(); // forces at most ONCE, then reuses } console.log("CALL-BY-VALUE:"); evals = 0; const v = useTwiceByValue(work("y")); // evaluated once, right now, before the call console.log(" result =", v, "| total evals =", evals); console.log("CALL-BY-NAME:"); evals = 0; const n = useTwiceByName(() => work("y")); console.log(" result =", n, "| total evals =", evals); console.log("CALL-BY-NEED:"); evals = 0; const d = useTwiceByNeed(() => work("y")); console.log(" result =", d, "| total evals =", evals);

Read the eval counts. The argument is used twice in the body, yet: call-by-value evaluates it once — but eagerly, before the call even begins; call-by-name evaluates it twice, once per use, redoing the work; call-by-need evaluates it once, lazily, on first use and then caches. Same answer (20), three different amounts of work.

Unused work: the strategy you can see

The used-twice case shows the cost difference; the ignored-argument case shows the correctness difference. Here constant never looks at y at all. Watch what runs:

function boom(): number { console.log(" BOOM — the expensive/erroring argument actually ran!"); return 999; } // constant ignores its second argument entirely. function constantByValue(x: number, y: number): number { return x; } function constantByName(x: number, y: () => number): number { return x; } console.log("CALL-BY-VALUE — argument evaluated before the call:"); const a = constantByValue(42, boom()); // boom() runs no matter what console.log(" result =", a); console.log("CALL-BY-NAME/NEED — argument passed unevaluated:"); const b = constantByName(42, () => boom()); // boom() never forced console.log(" result =", b);

Under call-by-value, "BOOM" prints — the wasted (or fatal) work happened regardless. Under call-by-name/need the thunk is never forced, so "BOOM" never prints. If boom had been an infinite loop, the call-by-value version would hang forever and the lazy version would still return 42. This is exactly the short-circuiting you already rely on in a \mathbin{\&\&} b: a lazy strategy makes every argument behave that way.

The three strategies side by side

Strategy Does unused work run? Evals of an arg used k times Example language
Call-by-value Yes — always, before the call 1 (eager, once) C, Java, Python, JS
Call-by-name No k (re-evaluated each use) Algol 60, Scala by-name
Call-by-need No 1 (lazy, then cached) Haskell

Read the middle column as the whole story in miniature. Call-by-need is the "best of both": it skips unused work like call-by-name, yet never repeats shared work, like call-by-value. The price is the bookkeeping — every argument carries a hidden thunk-and-cache.

Why not always be lazy?

If call-by-need never wastes work and never repeats it, why does almost every language pick eager call-by-value? Because eagerness is predictable. When you write x = expensive() in Python, you know precisely when the work happens and when its side effects fire: right there, right now. Laziness scatters that in time — the work happens "whenever it is first needed," which may be inside a completely different function, much later, or never. For pure computation (no side effects) that reordering is invisible and delightful; the moment I/O, mutation, or exceptions enter, the strategy becomes observable, and reasoning about order gets subtle.

Precisely because call-by-need makes evaluation order hard to see, Haskell needs a separate, explicit way to sequence side effects — that is what IO and the do-notation monad are for. Pure values can be evaluated in any order the runtime likes; effects cannot. So laziness and monadic I/O are two sides of one design decision: keep the language pure so the strategy stays invisible, and pull effects out into a structure that pins their order down. Lazy evaluation didn't just enable infinite lists — it forced an entire theory of effects.

Even eager languages sprinkle in laziness where it pays. a \mathbin{\&\&} b and a \mathbin{\|} b are call-by-name in disguise — the right operand is only evaluated if needed. So is the ternary c ? t : e. Scala lets you mark a parameter x: => Int to make it by-name; C's macros and #define are crudely by-name (they paste the expression in, re-evaluating it each time — the classic MAX(a++, b++) bug). You have been juggling strategies all along.