skip to content

When a compile-time expansion substitutes one argument expression at two places in its body, what does the caller lose?

level: middleimportance: should knowfreq 52%

answer

  1. substitution copies expression text
  2. count evaluations per path taken
  3. side effects repeat per occurrence
  4. compared value differs from returned value
  5. bind once into a fresh name

basics

~20 s

Substitution copies the argument's expression, not its value, so the expression is evaluated once per occurrence that control flow reaches. Side effects and cost repeat, and two evaluations can disagree. Bind it once into a temporary instead.

solid answer

~50 s

An expansion is not a call. It splices the argument's *expression text* into the body wherever the parameter appears, so if the parameter appears twice, the caller's expression appears twice in the compiled program and is evaluated once per occurrence that run-time control flow actually reaches. With a body like `if a > b then a else b`, an argument passed as `a` is evaluated by the guard, and then again by the `then` branch if the guard was true - so once or twice, not reliably either. The costs are repeated side effects, repeated work, and worst of all a value that disagrees with itself: the result returned was never the value that won the comparison. The fix is bind-once - evaluate each multiply-used argument into a single introduced binding and read that binding. Because the fix introduces a binding, it must use a freshly generated name, or it trades this bug for name capture.

code

pseudocode · 14 lines
pseudocode
macro larger_of(a, b):
    body:  if a > b then a else b

// call site
larger_of(next_reading(), limit)

// naive expansion: the argument stands at two places
if next_reading() > limit then next_reading() else limit
// guard reads the sensor; if it wins, the branch reads it AGAIN
// the value returned was never the value compared

// bind-once expansion, with a generated name
let g#1 = next_reading()
if g#1 > limit then g#1 else limit

go deeper

for a junior

Recall the one sentence that explains everything else: an expansion copies the argument's expression into the body, so a parameter used at two places means the expression is written at two places.

for a middle

Explain the mechanics: count evaluations per path rather than per occurrence, show that the guard and the taken branch each evaluate the expression, and describe binding the argument once before the body uses it.

for a senior

Show the production consequence - a construct that returns a value it never compared, on a live path, with no diagnostic - and describe a test that passes an argument with a counted effect to pin the evaluation count per path.

for a principal

Weigh whether a construct whose evaluation count depends on the caller's expression belongs in a shared surface at all, and what discipline you would require before another team's code can contain the expansion.

## The mechanism: expression in, expression out A compile-time **expansion** replaces a call to a macro-like construct with a copy of that construct's body, substituting each **argument** wherever the matching parameter appears. What is substituted is the argument's *expression*, not the value that expression would produce - expansion runs before the program is compiled, so nothing has been evaluated and there is no value to copy. If the parameter appears at two places in the body, the caller's expression now stands at two places in the compiled program, and each one is a separate evaluation at run time. This is the half of the hygiene problem that is about **evaluation count** rather than about names. The other half - a binding the expansion introduces colliding with one the caller had - has the same root cause: an expansion is not a function call, and everything the caller's intuition assumes comes from calls. ## Counting the evaluations honestly Take a body of the shape `if a > b then a else b`, called with an argument for `a` that has an observable effect. Trace the branch that actually fires: 1. The guard `a > b` evaluates the substituted expression once. 2. If the guard is **true**, the `then` branch evaluates it a second time - two evaluations. 3. If the guard is **false**, the `else` branch never mentions it - one evaluation. So "it runs twice because it appears twice" is as wrong as "it runs once because a call evaluates its argument once". The rule is: **once per occurrence that control flow reaches**. | Occurrence in the body | Reached when | Effect on an argument with a side effect | |---|---|---| | inside the guard | always | the effect happens | | inside the taken branch | guard selects it | the effect happens again | | inside the untaken branch | never on this run | no effect at all | ## What the caller loses - **Repeated side effects.** An argument that advances a cursor, consumes an item, appends to a log or mutates shared state performs that effect again. - **Repeated cost.** An expensive argument is paid for more than once, invisibly, at a call site that looks like one call. - **Disagreeing results.** This is the sharpest one. If the argument yields a different value on the second evaluation, the value the expansion *returns* is not the value that *won* the comparison. The construct's own contract is broken and the call site shows nothing. - **Order surprises.** With several multiply-used parameters, the interleaving of their effects is decided by the body's text, not by the order the caller wrote them. None of this produces a diagnostic. The program compiles, runs, and is wrong on the runs where the extra occurrence is reached. ## The bind-once fix Evaluate each multiply-used argument exactly once into an introduced binding, then have the body read that binding: 1. At the top of the expansion, declare one binding per multiply-used parameter. 2. Initialise each from the substituted expression, in the order the caller wrote the arguments. 3. Replace every occurrence of that parameter in the rest of the body with the binding. Now the count is exactly one, on every path, and the value compared is the value returned. ## Why the fix drags the naming problem back in The fix introduces a binding into the caller's block - which is precisely the setting in which **accidental capture** happens. If the introduced binding is called something ordinary, a caller that already has a name of that spelling is silently shadowed, and any argument text mentioning that name now resolves to the expansion's temporary. So bind-once is only safe when the introduced name is **freshly generated**: a name derived per expansion that no caller's source could contain. The two halves of hygiene are not independent rules; solving one correctly requires the other. ## When repeated occurrence is acceptable - The parameter appears exactly once in the body - nothing to repeat. - Every occurrence lies on mutually exclusive paths **and** the construct's contract does not depend on the values agreeing. - The argument is a plain name or literal. This is the weakest of the three, because the macro author does not choose the argument: the caller does, and the next caller may pass a call. Where expansion facilities differ is in how much help they give: some evaluate or bind arguments for you and generate the temporaries' names, others substitute text and leave both problems entirely to the author. The conservative discipline - bind once, name freshly - is correct under either. ## What an interviewer is listening for That you say "an expansion is not a call" and then reason from it: count the evaluations per path rather than per occurrence, name disagreeing values as the real bug rather than just wasted work, propose bind-once, and notice unprompted that the temporary you just introduced needs a generated name.

  • Why does the bind-once temporary have to carry a freshly generated name?
    Because the fix introduces a binding into the caller's block. An ordinary name such as a short temporary shadows a caller variable of the same spelling, and any argument text mentioning that name then resolves to the temporary. A generated name that no caller source can contain removes that risk.
  • Is it ever correct for an argument to appear at more than one place in the body?
    Yes, when the occurrences lie on mutually exclusive paths and the construct's contract does not require the values to agree - a branch that uses the argument only in the arm it selects, for instance. It stays fragile, because the caller chooses the argument and may later pass an expression with an effect.
  • How would you detect this in an existing expansion?
    Call it from a test that passes an argument with a counted observable effect, and assert the count for each path through the body. Reading the body and counting textual occurrences over-counts, because an occurrence on an untaken branch never runs.

saying these in an interview costs you the question

  • Says an expansion evaluates each argument exactly once, like a call
  • Says it runs twice because the parameter appears twice, ignoring untaken branches
  • Treats it as only a performance issue, not a correctness one
  • Assumes both evaluations must yield the same value
  • Fixes it by telling callers to avoid arguments with side effects
  • Binds the argument to an ordinary temporary name, trading the bug for capture