skip to content

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

level: middleimportance: should knowfreq 58%

answer

  1. no state to track anywhere
  2. each line means the same thing
  3. body in, arguments for parameters
  4. keep rewriting until no call remains
  5. a rebindable name breaks the chain

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.

solid answer

~50 s

You rewrite, you do not execute. Pick a call in the expression, replace it by the function's body with the argument values written in for the parameters, and reduce whatever that produced; repeat until no calls remain and a value is staring back at you. So `net(5000)` becomes `5000 - tax(5000) - pension(5000)`, then `5000 - 900 - pension(5000)`, then `5000 - 900 - 250`, then `3850`. Each line is a separate expression, and every one of them means the same thing as the line above — that is why you may stop at any point and still be looking at a correct description of the answer. The model only works while every call in the chain is pure and every name keeps denoting the same value for the whole trace; a name that can be rebound underneath you, or a call whose second evaluation disagrees with its first, breaks the chain at the step where it appears.

code

pseudocode · 11 lines
pseudocode
function tax(g)      return g * 0.18
function pension(g)  return g * 0.05
function net(g)      return g - tax(g) - pension(g)

net(5000)
= 5000 - tax(5000) - pension(5000)
= 5000 - (5000 * 0.18) - pension(5000)
= 5000 - 900 - pension(5000)
= 5000 - 900 - (5000 * 0.05)
= 5000 - 900 - 250
= 3850

go deeper

for a junior

Practise on a two-call expression until the mechanics are automatic: write the body, put the argument values in, simplify, repeat. Being able to produce the trace is worth more here than naming it.

for a middle

Explain that each line denotes the same value as the line above, and name what breaks the chain — a rebindable name, a call that disagrees with itself, a call that does something on the way.

for a senior

Use it as a review and debugging technique: localise a wrong payslip to the first line you disagree with, and say why no state had to be reconstructed to get there.

for a principal

The call is how much of a system you keep reducible this way, knowing the model dies at the first layer that rebinds names, and what that costs teams in onboarding and review speed.

## Rewriting, not running The substitution model is a way of saying what an expression **means** without talking about machines. There is no memory to look at and no instruction pointer to follow. There is only a sequence of expressions, each obtained from the previous one by a rewrite, each denoting the same value as the previous one, ending in a value that no longer contains a call. Working through a payroll expression this way is the single most useful thing purity buys a reader, and interviewers ask for the trace because it is impossible to fake. The one rewrite that does all the work is: **replace a call by the function's body, with the argument values written in for the parameters.** Everything else is ordinary arithmetic on the result. ## The procedure 1. **Find a call** in the current expression. 2. **Write out its body**, substituting the values of the arguments for every occurrence of the corresponding parameter. 3. **Reduce** any arithmetic or built-in operation now fully applied to values. 4. **Repeat** from step 1 until the expression contains no calls. The trace below takes six rewrites to get from `net(5000)` to `3850`, with `tax(g) = g * 0.18` and `pension(g) = g * 0.05`: ``` net(5000) = 5000 - tax(5000) - pension(5000) // body of net, 5000 for g = 5000 - (5000 * 0.18) - pension(5000) // body of tax = 5000 - 900 - pension(5000) = 5000 - 900 - (5000 * 0.05) // body of pension = 5000 - 900 - 250 = 3850 ``` Here the choice at each step was to take the innermost remaining call. A trace is a proof written down: any reader can check each line against the one above it without knowing anything about the rest of the program. ## What each column of the trace tells you | the rewrite | what it replaces | what it needs to be legal | |---|---|---| | unfolding a call | the call | the function's body, with arguments substituted for parameters | | reducing an operation | an operation applied to values | the operation's own definition | | folding a call back | a value | a definition that produces exactly that value | The third row is the same rewrite as the first, read right to left, and it is what you use when you go looking for the shorter expression that means the same thing. ## What breaks the model - **A name that can be rebound.** If `gross` denotes `5000` on line two and `4800` three lines later, the trace is no longer a chain of equal expressions; every line after the rebinding is about a different program. This is the deep reason a mutation-heavy style cannot be read this way: you must simulate the machine instead, tracking what each name currently holds. - **A call whose second evaluation disagrees with its first.** Substituting it means writing down one value where the program would have produced two, and the trace quietly stops describing the program. - **A call that does something on the way.** Replacing it by a value erases that happening from the trace, so the trace is no longer a faithful account of what the run does. - **Arithmetic that is not what you assumed.** Step 3 reduces an operation using its actual definition. If the numbers are represented with limited precision, the reduction is the one the representation performs, not the textbook one. ## Why it is worth practising Because debugging becomes reading. When a payslip comes out wrong, a substitution trace localises the defect to the first line you disagree with: everything above it is arithmetic you accept, everything below follows from it. You never have to ask "what was the state at that moment", because there is no state — only expressions. The same skill is what makes review fast: a reviewer who can unfold two calls in their head can tell whether the rewrite in the diff preserved meaning without checking out the branch. The trap to avoid at this level is confusing the model with an implementation. Real machines do not re-copy function bodies around; they push frames and jump. The substitution model claims nothing about how the work is carried out. It claims that the *answer* is the one the rewrite chain arrives at — which is precisely the claim a pure function lets you make.

  • What does the model have to be replaced by once a name can be rebound during the run?
    By a model with an environment: instead of a chain of equal expressions you track what each name currently holds, and you must say *when* you looked. Reasoning becomes a simulation of the machine over time rather than algebra on the page, and a fragment can no longer be understood without knowing what ran before it.
  • If a trace reaches a value, does that prove the program terminates for every input?
    No. A trace proves what one expression, with those argument values, reduces to. Another input can take a different path through the definitions and never reach a value at all. Termination for all inputs is a separate argument — usually that each recursive call works on a strictly smaller case.
  • Where do you stop unfolding when a helper is called from many places?
    Stop as soon as the line answers your question. A trace is a tool, not a ritual: if you only need to know that a deduction is proportional to gross pay, unfold until that is visible and leave the rest folded. Over-unfolding buries the point in arithmetic.

saying these in an interview costs you the question

  • Describes the machine's frames instead of rewriting the expression
  • Thinks a substitution trace shows how the runtime actually executes code
  • Keeps tracking variable state while claiming to substitute
  • Says a completed trace proves the function terminates for all inputs
  • Ignores that a rebindable name invalidates every later line