A reviewer hoists a repeated call out of a payroll loop — what makes that rewrite meaning-preserving?
answer
- cost changes, meaning does not
- two conditions, check both
- arguments fixed across iterations
- n evaluations collapse to one
- an empty loop evaluates it anyway
basics
~10 sIt 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.
solid answer
~50 sTwo conditions, and the reviewer has to check both. First, the call must be **loop-invariant**: its arguments do not change from one iteration to the next, so every iteration would compute the same value. Second, the function must be pure — same arguments give the same value, and evaluating it leaves nothing behind. Given both, the loop body's `rate(taxYear)` and a `rate` computed once above the loop denote the same value in every iteration, so replacing one by the other is just substitution applied `n` times. What changes is how many evaluations happen, from `n` to one — cost, not meaning. Drop purity and the rewrite is a behaviour change: a call that hands out the next payslip number gave `n` distinct numbers and now gives one, repeated. There is also an edge worth naming: if the loop can run zero times, hoisting evaluates a call the original never evaluated, which matters when that call can fail or not return.
code
pseudocode · 8 lines// before
for each employee in employees
net = employee.gross - employee.gross * rate(taxYear)
// after: arguments do not vary, and rate is pure
r = rate(taxYear)
for each employee in employees
net = employee.gross - employee.gross * rgo deeper
Remember the two conditions: the call's arguments must not change from iteration to iteration, and the function must be pure. Both, not either.
Explain that the rewrite is substitution applied once per iteration, so it collapses n evaluations into one and changes cost rather than meaning — and show the impure case where it changes output.
Bring the failure you have reviewed: an argument that only looked invariant, or a hoisted call that duplicated identifiers, plus the zero-iteration edge for a call that can fail or hang.
The lever is making the check mechanical rather than cultural — unchangeable bindings, signatures that expose what a call touches — so the rewrite family stays safe as the team grows.
## What the rewrite actually is Hoisting looks like a performance trick and is really an application of substitution. Inside the loop, `rate(taxYear)` denotes some value `v`. Above the loop, `rate = rate(taxYear)` binds that same `v`. Replacing the call in the body with the name that holds `v` is replacing an expression with something denoting the same value — legal exactly when the swap is legal, and for the same reasons. The reviewer's job is to check the two conditions that make it so. ## The two conditions 1. **Loop-invariance.** The call's arguments must not vary across iterations. `rate(taxYear)` where `taxYear` is fixed for the run is invariant. `rate(employee.region)` is not — hoisting it would pin every employee to the first employee's region, which is a defect that reads as a speed-up. 2. **Purity.** Same arguments must give the same value, and evaluating the call must leave nothing behind. If either fails, collapsing `n` evaluations into one changes what the program does, not merely how long it takes. With both satisfied, the rewrite changes the number of evaluations from `n` to `1`, where `n` is the number of iterations, and changes nothing about the values computed. That is the shape of the whole family: the rewrite is about cost; substitution is what guarantees it is *only* about cost. ## The family this belongs to The same licence covers three moves reviewers make constantly: | rewrite | what moves | why substitution licenses it | |---|---|---| | hoisting an invariant call out of a loop | one call, upward | every iteration denoted the same value | | naming a repeated subexpression | several occurrences, into one name | all occurrences denoted the same value | | inlining a one-line helper | a call, into its body | the call denoted what the body denotes | None of these need you to read the rest of the program — which is the practical payoff. In a style where any of the three might have been doing something on the way, each of them is a change you must justify by inspecting every caller. ## Where it goes wrong in production - **The argument was not invariant after all.** The classic version is an argument that looks fixed but is reached through a structure that the loop body itself updates. Every payslip then uses the first iteration's value, and the totals are plausible enough to survive review. - **The call was not pure.** A call handing out the next payslip number produced `n` distinct numbers; hoisted, it produces one number used `n` times. The loop still runs, the output still looks like payslips, and the duplicate identifiers surface downstream. - **The loop can run zero times.** The original evaluated the call never; the hoisted version evaluates it once. If the call can fail on the current inputs, or can take a long time, or can fail to return at all, an empty employee list now behaves differently from before. For a total, cheap, pure call this is invisible; it is the case worth a sentence in review when the call is none of those things. - **The result is modifiable and iterations modify it.** `n` evaluations produced `n` independent results; the hoisted version produces one result shared by every iteration. If the body writes into it, the iterations now see each other's writes. ## Reviewing the rewrite The question to ask in the review is not "is this faster" — it usually is — but **"would every iteration have computed the same value, and did evaluating it do anything else?"** If both answers are yes and no, the rewrite is safe by construction and needs no further argument. If either is uncertain, the rewrite needs the uncertainty resolved before it needs a benchmark, because a hoist that changes meaning is a correctness defect wearing a performance-improvement label — and payroll is a domain where that defect ships to people's bank accounts. The converse direction is equally licensed and occasionally what you want: pushing a call back into a loop, so that each iteration computes its own value. That is the right move when the call was hoisted before someone made its argument vary, and it is the same substitution read right to left.
- The loop can run zero times. Does hoisting still preserve meaning?For a cheap, total, pure call, yes — nothing observable differs. Otherwise no: the original never evaluated the call for an empty list, and the hoisted version always does. If the call can fail on these inputs, cost real time, or not return, an empty employee list now behaves differently, which is exactly the case to flag in review.
- How would you make the invariance check cheap for a reviewer instead of a judgment call?Make it visible in the signature and the binding. A call whose arguments are all names bound before the loop, none of which the body rebinds, is invariant by inspection. Where the language allows a binding to be declared unchangeable, declaring it turns the check into something the build verifies rather than something a reader remembers.
- If the hoisted call is pure but expensive, is hoisting always the better code?Usually, but not by default. It trades repeated evaluation for one evaluation plus a value held across the loop, and it moves work earlier — which can be wrong when the loop often exits on the first iteration or runs zero times. The rewrite is meaning-preserving either way; which version to ship is a cost judgment.
saying these in an interview costs you the question
- Calls hoisting a pure optimisation with no conditions to check
- Checks purity but never asks whether the arguments vary per iteration
- Thinks fewer evaluations cannot change behaviour
- Hoists a call that hands out a fresh value each time
- Ignores the zero-iteration case for a call that can fail or hang