skip to content

Purity & Referential Transparency

What makes a function pure, and everything purity buys: substitution, caching, reordering, a testable core. Interviewers start here because the rest of the paradigm rests on it.

on this pageshow

questions

22

A function that decides whether a subscription renews reads the clock itself — what does that cost you?

level: juniorimportance: must knowfreq 68%

answer

  1. two calls, two answers
  2. time is an input, not a lookup
  3. the signature is lying
  4. read the clock once at the edge
  5. pass the instant, not the clock

basics

~20 s

Reading the clock inside the decision turns time into an undeclared input: the same subscription record can produce two different answers, and no test can pin the answer down. Pass the instant in as a parameter.

solid answer

~40 s

The function really has two inputs — the subscription record and the instant it happened to run at — and only the first is in the signature. That makes it impure: same arguments, different result. A test then has to move the machine clock or accept a case that passes today and fails on a renewal boundary, and a wrong decision in production cannot be replayed because the input was never recorded. It also lets two reads inside one run straddle a boundary and disagree. The fix is to make the hidden input visible: take `now` as a parameter, read the clock once at the outer edge of the program, and hand that same instant to every decision in the run.

code

pseudocode · 14 lines
pseudocode
// hidden input: the decision reaches for the world itself
function shouldRenew(subscription)
    now = currentInstant()            // nothing in the signature says this
    return subscription.paidThrough < now

// declared input: the caller supplies the instant
function shouldRenew(subscription, now)
    return subscription.paidThrough < now

// the edge takes one reading for the whole run
function runRenewals()
    now = currentInstant()
    for each s in dueSubscriptions
        if shouldRenew(s, now) then charge(s)

go deeper

for a junior

Recall both halves of the definition and apply them: a pure function returns the same result for the same arguments and changes nothing observable. A clock read breaks the first half even though it writes nothing.

for a middle

Explain that the instant is an undeclared parameter, and show the rewritten signature that takes it. Say why the reading is taken once per run rather than once per decision.

for a senior

Talk about the incident you could not reproduce because the deciding input was never recorded. Passing the instant in means a failing run replays exactly from the value in the log.

for a principal

Frame it as a convention for other teams: name the places in the system allowed to read a clock at all, and weigh the reproducibility that buys against the cost of threading one more argument through every layer.

## Two inputs, one of them undeclared A function is **pure** when two things hold: the same arguments always produce the same result, and the call changes nothing anyone else can observe. A renewal check that reads the current time writes nothing at all, so it is tempting to call it pure. It is not, and the half it fails is the first one. The honest input list of `shouldRenew(subscription)` is the subscription record **and the instant the call happened to run at**. Only the record appears in the signature. The instant arrives through a side door. That is a **hidden input**: a value the result depends on that the caller cannot see, cannot set and cannot record. Hidden inputs cost three concrete things: - **Determinism.** Two calls with an identical record can disagree, and nothing in the code explains why. - **A cheap test.** To exercise "the day after the paid-through date", the test must move the machine's clock or wait for the calendar to reach it. - **Reproduction.** When a decision was wrong in production, the value that produced it was never written down, so there is nothing to replay. ## Promoting the hidden input The repair is small and mechanical: 1. Find every reach for the outside world inside the decision — the clock, a random draw, an environment lookup, a counter that advances. 2. Add it to the parameter list as a **value**, not as a source of values. 3. Take the reading once, at the outer edge of the program, where the run begins. 4. Hand that one reading down to every decision the run makes. Step 4 carries more weight than it looks. If each decision reads the clock for itself, two decisions in one run can land on either side of a boundary — a renewal date, a period rollover — and contradict each other for a reason no reader can see in the code. One instant per run makes the run internally consistent, and a log line recording that instant is then enough to recompute every decision the run made. ## Passing a value is not the same as passing a source | arrangement | same arguments, same result | test needs the clock controlled | two reads can disagree | |---|---|---|---| | reads the clock inside itself | no | yes, or the test waits | yes | | is handed something it can ask for the time | no | yes, a stand-in is supplied | yes | | is handed the instant as a value | **yes** | no | no | Being handed an object it can ask for the time is a real improvement: a test can supply a stand-in and the case becomes repeatable. But the function still returns different answers for the same arguments across calls, because one of its arguments is a door to the outside rather than a value. It has become **controllable**; it is not yet **pure**. The distinction is worth keeping, because the payoffs people actually want here — replaying a run from a log, reasoning about a decision from its arguments alone, running cases in any order — come from purity, not from controllability. ## Randomness has the same shape Anything the decision samples rather than receives is this defect in different clothes: a random draw, a freshly generated identifier, a counter, a lookup of the current locale. Two treatments keep the deciding function pure: - **Draw at the edge.** The outer layer takes the values it needs and passes them in, and the decision is a plain calculation over them. - **Thread a seed.** The decision takes a seed and returns the drawn value together with the next seed, so the whole sequence is a function of what came in. What does not work is being handed a generator that advances internal state on every call. That is the clock again: the argument is a source, its state changes between calls, and identical arguments yield different results. ## What this actually buys It does not make the program do less I/O — the clock is still read, exactly once, somewhere. It relocates the read to a place where it is visible and recordable, and leaves the decision as a calculation whose behaviour is fully determined by the values on its argument list. That is what lets a test state a case as "this record, this instant, expect this" with no fixture at all, and what lets a bad production run be replayed exactly from the arguments it was given. One last caution about scope: promoting the instant fixes the decision, not the program. Somebody still reads the clock, and that somebody is now the outer layer, whose own correctness has to be checked in a slower way.

  • If the outer layer hands the decision an object it can ask for the current time, is the decision pure?
    No — it is controllable, not pure. Asking the object still returns a different value on each call, so the same arguments can produce two answers. You have made the dependency visible and substitutable, which is enough for a repeatable test, but the result still depends on something outside the argument list.
  • Why read the clock once per run instead of once per decision?
    Two reads in one run can fall on either side of a boundary — a renewal date, a period rollover — so decisions that should agree disagree, and nothing in the code explains it. A single instant taken at the edge makes the run internally consistent and lets the whole run be recomputed later from one recorded value.
  • How does the same treatment apply to randomness?
    Identically. Either draw the values at the edge and pass them in, or pass a seed and use a routine that returns the drawn value together with the next seed. What fails is a generator that advances hidden state on each call: the argument is a source rather than a value, so identical arguments still give different results.

saying these in an interview costs you the question

  • Claims the function is pure because it writes nothing
  • Says one clock read is too small to affect purity
  • Wants the test to wait for or move the machine clock
  • Reads the clock twice in one run and expects agreement
  • Thinks being handed a clock object already makes it pure
open as a page

A trip planner caches each route-distance result by its pair of stops - why does that cache never go stale?

level: juniorimportance: must knowfreq 65%

basics

~20 s

A pure lookup returns the same distance for the same pair of stops every time, so a stored result can never disagree with a fresh call. Invalidation exists to catch drift between cache and source; purity removes the drift.

open as a page

Two pure cell formulas in a recalculation pass — when may the engine evaluate them in either order?

level: juniorimportance: must knowfreq 66%

basics

~20 s

Either order is safe exactly when neither formula reads the cell the other writes. Purity closes every other channel — no shared counters, no clock, no files — so only declared data dependencies constrain the schedule.

open as a page

A delivery-fee function reads the clock for a cut-off, bumps a metrics counter and writes a log line — which of those make it impure?

level: juniorimportance: must knowfreq 84%

basics

~20 s

A clock read, a counter bump and a log write are all side effects. Purity needs two things at once: the result depends only on the arguments, and the call changes nothing observable outside itself.

open as a page

In a payroll calculation, what does it mean that a call may be replaced by its result?

level: juniorimportance: must knowfreq 72%

basics

~10 s

It means the call is referentially transparent: anywhere the call appears, writing the value it returned instead leaves the meaning of the program unchanged, and putting the call back is equally safe.

open as a page

Your renewal job loads accounts, decides, then charges cards — how do you arrange it so the decision stays pure?

level: middleimportance: must knowfreq 58%

basics

~20 s

Split the job into three phases: an outer layer reads everything the decision could need, a pure function turns those values plus the instant into decisions, and the outer layer carries each decision out. No reads or writes in the middle.

open as a page

When does memoizing a trip planner's route-distance lookup stop paying for the memory it holds?

level: middleimportance: must knowfreq 55%

basics

~20 s

Memoization buys time with space, so it pays only when keys repeat often enough. It stops paying when arguments are near-unique, when the computation is cheaper than building and comparing the key, or when retained entries starve the rest of the process.

open as a page

Before spreading a column of pure cell formulas across workers, what must a scheduler establish about each one?

level: middleimportance: must knowfreq 58%

basics

~20 s

That each formula reads only its own row's inputs, writes only its own output slot, and reads nothing that the pass mutates. With all three true, workers can take any partition with no locks and no ordering.

open as a page

A fee calculator sums line items into a local mutable accumulator — does that mutation make the function impure?

level: middleimportance: must knowfreq 61%

basics

~10 s

No. Purity forbids observable change, not assignment. A variable created, mutated and discarded inside one call cannot be reached by any caller, so the mutation is invisible and the function stays pure.

open as a page

Why can a pure renewal core be tested without standing anything in for the clock or the account store?

level: middleimportance: should knowfreq 52%

basics

~20 s

Nothing needs standing in because the core never calls out: the instant and the account records arrive as ordinary arguments. A test builds those values, calls the function once, and compares the decisions it returns.

open as a page

A recursive route-cost search keeps re-asking identical sub-route queries - what does memoizing the call change about its total work?

level: middleimportance: should knowfreq 40%

basics

~20 s

It collapses the work from the number of calls the recursion makes to the number of distinct arguments it ever asks about. Each distinct query is computed once; every repeat becomes a table hit, which is what turns a branching explosion into a bounded count.

open as a page

A memoized route-distance lookup keyed by two stop values almost always misses - what should you suspect about the key?

level: middleimportance: should knowfreq 48%

basics

~20 s

That entries are being compared as distinct when they should be considered the same. A key compared by reference, or one carrying a field the distance does not depend on, gives every caller a fresh entry even though the arguments mean the same thing.

open as a page

Twenty cell formulas contain the identical pure subexpression — what may the engine do that it could not otherwise?

level: middleimportance: should knowfreq 48%

basics

~20 s

Evaluate that subexpression once for the pass and feed the single value to all twenty formulas. Purity makes the elimination sound: the same inputs give the same value, and skipping nineteen evaluations skips no effect.

open as a page

The fee calculator throws when a postcode is unserviceable — does throwing break the function's purity?

level: middleimportance: should knowfreq 44%

basics

~20 s

It depends on what decides the throw. A throw fixed by the arguments consults nothing outside and writes nothing outside, so the function stays deterministic — but it stops being total, because some inputs now yield no value at all.

open as a page

How do you evaluate a nested payroll expression by the substitution model, one step at a time?

level: middleimportance: should knowfreq 58%

basics

~20 s

Replace a call with its body, substituting the argument values for the parameters, then reduce what results; repeat until only a value is left. Every rewrite preserves meaning, so the final value is the expression's value.

open as a page

Two teams computed the same payroll deduction with different expressions — how do you prove the two always agree?

level: middleimportance: should knowfreq 40%

basics

~20 s

Unfold both expressions by substituting their definitions, then rewrite each using laws that genuinely hold for the operations involved until both reach a common form. Equal forms prove agreement for every input the laws cover.

open as a page

Months on, your renewal shell is full of conditionals while the pure core barely decides anything — what happened?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Decisions leaked outward, usually because a rule needed one more record and someone wrapped a condition around the fetch instead of feeding the data in. Every such branch is logic living in the half that only slow tests reach.

open as a page

A long-running planner's memo table for route distances grows without bound - how do you bound it without risking wrong answers?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Any bound is safe, because dropping an entry of a pure lookup only costs a recomputation. Cap the entry count with a recency policy, scope the table to a unit of work so it dies naturally, or shrink the key space - then measure the hit rate you kept.

open as a page

A recalculation pass stopped running in parallel after one formula began recording each evaluation — why did unrelated formulas slow down?

level: seniorimportance: should knowfreq 42%

basics

~20 s

The recording is a channel the analysis cannot see through, so independence can no longer be proved for the pass. Optimisers reason conservatively over a whole region, and one shared write pins the schedule for everything inside it.

open as a page

Every line of the fee calculator is arithmetic, yet the audit calls it impure — how can that be?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Effects are inherited through calls. A function is at most as pure as everything it calls, so a clock read, a configuration lookup or a shared write hiding one level down in a helper makes the arithmetic above it impure too.

open as a page

A reviewer hoists a repeated call out of a payroll loop — what makes that rewrite meaning-preserving?

level: seniorimportance: should knowfreq 46%

basics

~10 s

It preserves meaning when the call's arguments do not vary across iterations and the function is pure, so every iteration would have produced the same value and evaluating it once loses nothing observable.

open as a page

Reduction order cannot change a pure expression's result — what does that guarantee leave unconstrained?

level: seniorimportance: nice to knowfreq 26%

basics

~10 s

Everything except the value: how much work is done, how much memory is held, how long it takes, and whether a chosen order finishes at all. Confluence promises one answer, never one cost.

open as a page