skip to content

An expansion's body calls a helper by name - in whose scope should that name resolve at expansion?

level: seniorimportance: nice to knowfreq 28%

answer

  1. capture has a second direction
  2. introduced, supplied, or free
  3. the body mentions what it never declared
  4. resolve free names where they were written
  5. explicit opt-out, never accidental crossing

basics

~20 s

In the scope where the body was written, not where it is expanded. Otherwise a caller who happens to define that name silently substitutes their own helper for the author's, changing what the expansion does without touching it.

solid answer

~50 s

This is capture running the other way. A **free name** is one the body mentions but does not introduce and did not receive as an argument - a helper, a constant. Naive substitution resolves it wherever the text lands, so a caller with a definition of the same spelling silently replaces the author's. A hygienic expansion binds free names at the **definition site**, so what the body calls is what its author could see, regardless of what the caller has in scope. The practical value is that an expansion's behaviour is a property of the expansion, not of each call site's namespace. Facilities differ in whether they offer this, and some provide a deliberate escape hatch so an author can intentionally reach into the caller's scope - which is fine when it is the documented point, and a defect when it happens by accident.

code

pseudocode · 13 lines
pseudocode
// definition site
function helper(x):  return x * 2
macro doubled(v):
    body:  helper(v)

// call site, in another file
function helper(x):  return x - 1      // unrelated local helper
let r = doubled(5)

// naive substitution: the spliced text resolves here
//   helper(5) -> the caller's helper -> 4
// hygienic: free name bound at the definition site
//   helper(5) -> the author's helper -> 10

go deeper

for a junior

Recall that a body can mention names it neither declares nor receives, and that which definition those reach is a real question rather than an obvious one.

for a middle

Explain the three kinds of name in a body - introduced, supplied, free - and say where each should resolve and what breaks when it resolves at the other site.

for a senior

Show why the author cannot defend with naming conventions, since callers are unknown, and argue the guarantee belongs in the facility; describe the explicit opt-out and what documenting it costs the caller.

for a principal

Judge whether a shared construct may depend on anything in a caller's namespace at all, and what you require in the contract when one deliberately does.

## Two directions of capture Hygiene is usually introduced through one failure - a binding the expansion introduces shadowing a caller's variable - but the problem is symmetric, and the second direction is the one interviews use to separate familiarity from understanding. - **Introduced names capturing the caller.** The body declares a temporary; the caller had one of the same spelling; the caller's references now mean the temporary. - **The caller capturing the body's free names.** The body mentions something it did not declare and was not passed - a helper function, a shared constant. Naive substitution resolves it against whatever is visible where the text landed, so the caller decides what the author's body calls. The second is more dangerous in one specific way: the first corrupts a caller's own data, while the second changes the behaviour of a construct many callers share, one call site at a time. ## What a free name is Inside an expansion body, every name falls into one of three buckets: 1. **Introduced** - declared by the body itself. These are the ones that must be renamed to generated names. 2. **Supplied** - substituted in from the caller as an argument. These must be left exactly as written, so they keep meaning what the caller meant. 3. **Free** - mentioned, but neither declared nor supplied. These are the subject here. | Name kind | Should resolve at | If it resolves at the other site | |---|---|---| | introduced | nowhere the caller can see - a generated name | the caller's variable is shadowed | | supplied | the caller's scope | the caller's argument silently changes meaning | | free | the definition site | the caller's definition hijacks the body | ## The failure, concretely An author writes a construct whose body calls `helper`, meaning the `helper` beside the definition. A caller's file defines its own `helper` for unrelated reasons and uses the construct. Under naive substitution the expanded call reaches the caller's `helper`, so the construct returns something its author never wrote and never tested. The code compiles: both helpers exist and have compatible shapes. It is wrong only in this file, and only because of a name the author never heard of. The author cannot defend against this by choosing better names, because they do not know the callers. That asymmetry is exactly why the guarantee belongs to the facility rather than to a convention. ## Referential transparency of the body The property a hygienic system gives is that **the body means what it meant where it was written**. Concretely: - A free name is resolved once, against the definition site's scope, and carries that binding into every expansion. - Adding a definition at a call site cannot change an already-correct expansion. - Reading the body tells you what it does - the only thing the call site contributes is the arguments. This is the difference between a construct you can reason about and a construct whose meaning is a function of every namespace it is pasted into. ## The deliberate exception Not every reach into the caller's scope is a bug. Some constructs exist precisely to introduce a name the caller then uses, or to refer to something the caller is required to have in scope, and some facilities provide an explicit way to opt out of hygiene for a named identifier. The rule is therefore not "never touch the caller's scope" but: 1. Hygiene is the **default**, so nothing crosses the boundary by accident. 2. Any crossing is **explicit in the body's source**, so a reader sees it. 3. Any crossing is **documented as part of the construct's contract**, because it is now a requirement on the caller. An accidental crossing and a documented one produce the same expanded text; only the first is a defect. ## What an interviewer is listening for That you can name both directions of capture without merging them, place free names in the definition site, and justify it by ownership: the author chose the body's names and cannot see the callers, while the caller chose the arguments and cannot see the body. Mentioning that deliberate, explicit unhygiene is a legitimate tool - and that the word doing the work is *explicit* - marks the answer as lived rather than recited.

  • Why not simply require authors to pass every helper in as an argument?
    It works and is sometimes the right design, but it pushes the body's private dependencies into its public signature, so every caller must supply machinery it should not know about. Resolving free names at the definition site gives the same guarantee without enlarging the contract.
  • When is letting a name resolve in the caller's scope the intended behaviour?
    When introducing or reaching a name is the construct's documented purpose - a binding the caller is meant to use inside the block, or a value the caller is required to have. It is legitimate only when opted into explicitly in the body's source and stated in the contract, so it is never a surprise.

saying these in an interview costs you the question

  • Thinks hygiene concerns only the names an expansion introduces
  • Says the caller's definition should win because expansion happens there
  • Assumes the site where spliced text lands always decides resolution
  • Confuses it with an introduced temporary shadowing a caller's variable
  • Claims there is never a legitimate reason to reach the caller's scope