skip to content

An expansion introduces a temporary binding into the caller's block - how can that silently change the caller's meaning?

level: seniorimportance: must knowfreq 58%

answer

  1. the body lands in the caller's scope
  2. same spelling, different binding
  3. shadowing is legal, so nothing complains
  4. the caller's argument text changes meaning
  5. generated names, not unusual ones

basics

~20 s

The introduced binding shadows a caller name of the same spelling, so the caller's own references - including argument text spliced into the body - resolve to the expansion's temporary instead. This is accidental capture, and it compiles cleanly.

solid answer

~50 s

Expansion splices the body into the caller's block, so any binding the body declares lands in the caller's scope. If the caller already has a name of that spelling, the introduced one **shadows** it for the rest of the expanded region - and the argument expressions the caller wrote were substituted *into* that region, so they now refer to the expansion's temporary rather than to the caller's variable. That is accidental capture. Nothing diagnoses it: shadowing is ordinary, legal code, so the build succeeds and the program computes the wrong value. The cure is a renaming discipline - every binding the expansion introduces gets a **freshly generated** name, derived per expansion, that no caller's source could contain. Facilities differ in whether they do this for you or leave it to the author, so an author must know which they have.

code

pseudocode · 24 lines
pseudocode
macro repeat_twice(action):
    body:
        let i = 0
        while i < 2:
            action
            i = i + 1

// call site: the caller has its own i
let i = find_index()
repeat_twice( log(i) )

// naive expansion - the introduced i shadows the caller's
let i = find_index()
let i = 0
while i < 2:
    log(i)          // logs 0 then 1, NOT find_index()
    i = i + 1

// hygienic expansion - introduced binding renamed, argument untouched
let i = find_index()
let i#4 = 0
while i#4 < 2:
    log(i)          // still the caller's i
    i#4 = i#4 + 1

go deeper

for a junior

Recall that an expansion's body ends up inside the caller's block, so a variable the body declares is a variable in the caller's scope and can hide one the caller already had.

for a middle

Explain the resolution step: the introduced binding shadows the caller's, the argument text was substituted into that region, and shadowing is legal so the build stays green.

for a senior

Diagnose the live version - a wrong value with no diagnostic, reproducing only for callers whose names collide - and describe the renaming guarantee plus a test that forces the collision deliberately.

for a principal

Decide what a shared codebase requires before one team's expansion may land inside another's blocks, and what you accept when the facility offers conventions rather than automatic renaming.

## Where the two scopes meet An expansion does not run in a world of its own. Its body is spliced into the caller's block **before compilation**, so from the compiler's point of view there is only one piece of code: the caller's, with the body's text now part of it. Every binding the body declares is therefore a binding in the caller's scope, and every name the caller passed as an argument is resolved *inside* that combined region. That collision is the whole subject. Two sources of names - the expansion author's and the caller's - end up sharing one namespace that neither of them can see whole. ## Accidental capture, step by step Suppose a construct repeats an action a fixed number of times, and its body needs a counter, so it declares one called `i`. A caller who happens to have a variable called `i` writes an argument that mentions it. After expansion: 1. The caller's `i` is declared and holds the value the caller computed. 2. The expansion's `i` is declared next, in the same block, **shadowing** the caller's for the rest of the region. 3. The argument text, which the caller wrote meaning "my `i`", was substituted into that region. 4. Every reference in it now resolves to the loop counter. The caller reads an unfamiliar sequence of values and there is no error anywhere. This is **accidental capture**: a name the expansion introduced has captured a reference the caller intended for its own binding. | What the caller wrote | What the caller meant | What the expanded code does | |---|---|---| | an argument mentioning `i` | the caller's own `i` | reads the expansion's counter | | code after the call using `i` | the caller's own `i` | depends on where the introduced binding's scope ends | | nothing about `i` at all | unaffected | unaffected - the collision needs a shared spelling | ## Why it is silent - **Shadowing is legal.** An inner binding hiding an outer one of the same name is ordinary code that authors write deliberately, so a compiler that rejected it would reject correct programs. - **The two declarations do not look like duplicates.** They are in a nesting relationship, not a redeclaration in one scope. - **The call site is short.** The reviewer sees one line naming a construct; the shadowing lives in text they never read. - **The symptom is a wrong value, not a crash.** It surfaces far from its cause, and only for callers whose names happen to collide. ## The renaming discipline The cure is to make collision impossible rather than unlikely: - **Every binding the expansion introduces is renamed** to a name generated afresh for that expansion - unique per expansion site, and drawn from a space the caller's source cannot express. - **Names the caller supplied are not renamed.** Argument text must keep meaning what it meant in the caller's scope; renaming it would break the caller in the opposite direction. - **The rename is systematic, not a habit.** A rare-looking temporary name is a hope; a generated name is a guarantee. A codebase large enough will eventually contain the rare name. Where facilities differ is in who performs the rename. Some perform it automatically as part of expansion, so an author writing an ordinary temporary name still gets a captured-proof result. Others substitute text and give the author only conventions - a reserved prefix, a naming rule - which are enforceable by review rather than by the toolchain. An author's first question about an unfamiliar facility should be which of the two they are holding. ## Distinguishing it from its neighbours Two failures sit next to this one and are constantly conflated: - An argument **evaluated more than once** because the parameter appears at two places is about evaluation count, not naming. Its own fix - binding the argument once - introduces a binding, and so depends on this one. - A **free name inside the body** resolving in the caller's scope rather than where the body was written is capture in the opposite direction: the caller captures the expansion, rather than the expansion capturing the caller. A candidate who can state which direction a given bug runs in has understood hygiene; one who says only "names can clash" has not. ## What an interviewer is listening for That you locate the failure in **scope**, not in syntax: the body's bindings land in the caller's block, shadowing is legal so nothing complains, and the caller's own argument text is the thing that quietly changes meaning. Then that you reach for generated names as a guarantee rather than an unusual spelling as a hope.

  • In a hygienic expansion, which names are renamed and which must be left alone?
    Bindings the body introduces are renamed to generated names. Names the caller supplied as arguments are left exactly as written, because they must keep resolving in the caller's scope. Renaming those would break the caller in the opposite direction, turning a working reference into an unbound or wrong one.
  • A team's convention is to prefix every introduced temporary with an unusual marker. Is that equivalent to hygiene?
    No. It is a reduction in probability enforced by review, not a guarantee enforced by the toolchain. A caller may legitimately use the prefix, and nothing detects it when they do. It is a reasonable fallback when the facility offers no automatic renaming, not a substitute for one.
  • How would you write a test that would have caught this capture?
    Call the expansion from a block that deliberately declares variables with the same names the body introduces, and assert the caller's values are unchanged and its arguments observed the caller's data. Capture only manifests when the spellings collide, so the test must force the collision.

Two writers editing one page: if the second silently reuses a footnote number the first already used, every reference to it now points at the wrong note - and the page is still perfectly well-formed.

saying these in an interview costs you the question

  • Says the build would fail on a duplicate name, so it is caught
  • Thinks capture only involves globals, not block-local names
  • Treats an unusual temporary name as a guarantee rather than a hope
  • Assumes every expansion facility renames introduced names automatically
  • Confuses it with the argument being evaluated more than once
  • Says renaming should also apply to names the caller passed in