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
Here is the sharpest possible test. A function that takes two arguments and simply throws the second away:
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:
Should the answer be constant never touches
y, the dangerous expression is never forced, and the answer is a calm
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:
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 (
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:
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
=> by-name params.
| Strategy | Does unused work run? | Evals of an arg used |
Example language |
|---|---|---|---|
| Call-by-value | Yes — always, before the call | C, Java, Python, JS | |
| Call-by-name | No | Algol 60, Scala by-name | |
| Call-by-need | No | 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.
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. 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.