skip to content

A shared formatting helper produces different output depending on which module calls it - which scoping rule explains that?

level: middleimportance: must knowfreq 58%

answer

  1. the path decides, not the text
  2. resolved against live call frames
  3. innermost active binding wins
  4. an input that is in no signature
  5. a caller's rename breaks the callee

basics

~20 s

Caller-driven, or dynamic, scoping explains it: a free name in the helper is resolved against the chain of calls active at that moment, so each caller's own binding of that name supplies a different value.

solid answer

~40 s

Under **dynamic scoping** a free name is resolved at run time by searching the frames of the calls currently active - the immediate caller first, then its caller, outward - and the innermost binding found wins. The helper's text is identical on every path, but the environment it is resolved against is whatever the current call chain happens to hold, so two callers that each bind the name differently get different output from one body. The practical consequence is that the helper has an **invisible input**: nothing in its signature says it depends on that name, and nothing in its own file says where the value comes from. A caller can therefore change the helper's behaviour, or break it outright by renaming its own local, without touching the helper or even knowing it exists.

code

pseudocode · 10 lines
pseudocode
function format(message)
    return prefix + ': ' + message     // prefix is free - no local, no parameter

function ship()
    let prefix = 'ship'
    return format('done')              // caller-driven lookup finds 'ship'

function audit()
    let label = 'audit'                // the same local, renamed last week
    return format('done')              // caller-driven lookup finds no prefix here

go deeper

for a junior

Recall that a name a body does not bind has to be answered by something else, and that one possible rule is to ask whoever called. Notice that the helper's text alone then cannot tell you the answer.

for a middle

Describe the outward search through active call frames, first-match-wins, and be able to name the three failures: a caller accidentally supplying a binding, accidentally removing one, and a function in the middle interposing its own.

for a senior

Diagnose the symptom under pressure: distinguish a changing declaration from a changing value behind one declaration, and use the caller-rename test to tell them apart before you start reading the helper.

for a principal

Set the standard: caller-supplied values are parameters or explicitly installed named bindings with defaults, never ordinary free names. The property you are buying for the team is that a helper's inputs can be enumerated from one file.

## Same text, different environment The helper body is fixed: it reads a name it does not bind and puts it in the output. What varies is the environment that answers that name. Under caller-driven resolution the environment is assembled from the calls that are live at that instant, so the answer depends on the **path taken to reach the call**, not on anything visible in the helper. That gives a helper a peculiar kind of input: - It does not appear in the parameter list. - It does not appear in the text around the definition. - It cannot be seen by reading either the helper or its caller in isolation - you need the whole path. - It changes when an unrelated function between the caller and the helper happens to bind the same name. ## How the search actually runs 1. The call reaches the helper and hits the free name. 2. The run time looks at the frame of the function that called the helper, asking whether it binds that name. 3. If not, it looks at that function's caller, then its caller, and so on outward. 4. The first frame holding a binding answers; the search stops there. 5. If no active frame binds it, the lookup fails at that moment, on that path only. Step 5 is the part engineers underestimate. Under this rule a helper can work perfectly for months and then fail the first time it is reached through a path that never bound the name. The failure is not in the changed code; it is in the code that did not change. ## The three failure shapes | Shape | What happens | Why it is hard to find | |---|---|---| | Accidental supply | A caller binds a name for its own purposes and the helper silently picks it up | Nothing in either file links the two names | | Accidental removal | A caller renames its own local and the helper's lookup now fails or reaches further out | The rename looks purely local and reviews as harmless | | Interposition | A function in the middle of the chain binds the name and shadows the one the caller intended | Neither end of the chain mentions the middle | All three have the same root: the binding relationship is established by *co-occurrence on the call stack*, and the stack is not something either file declares. ## Why the lexical answer is the default Contrast the same helper under lexical resolution. Its free names are answered by the text enclosing its definition, which is fixed when the file is written. Two consequences follow immediately: - **Local reasoning.** The helper's inputs are its parameters plus the declarations visible around it. A reviewer reading one file can enumerate them. - **Stability under motion.** Calling the helper from a new place, deferring the call, or storing it and invoking it later cannot change which declaration its names refer to. Under caller-driven resolution both of those are lost, which is why nearly every mainstream language made lexical the default and left caller-driven bindings, where they exist at all, as an explicit and separately named facility rather than the behaviour of ordinary free names. ## Diagnosing it when you meet it When one body genuinely produces different results on different paths with the same arguments, there are only a few live explanations, and it is worth separating them: - The helper reads a value supplied by the current call chain rather than by its own text - the case described here. - The helper reads mutable state that some callers have updated and others have not; the name resolves to the same declaration each time, but the value behind it differs. - The helper takes a parameter that differs between callers and you have not spotted it. The distinguishing test is whether the *declaration* being reached changes, or only the *value* behind one fixed declaration. If a caller can break the helper by renaming one of its own locals, the declaration is being chosen by the caller, and you are looking at caller-driven resolution. ## The takeaway for design When a value really is chosen by the caller, make it a parameter or an explicitly installed, named binding with a documented default. What you want to avoid is the situation where an ordinary-looking free name quietly means "whatever the stack happens to be holding" - because that is an input nobody can find from either side of the call.

  • Could the same symptom be caused by shared mutable state instead?
    Yes, and separating the two matters. With shared mutable state, every path reaches the same declaration and only the value behind it differs. With caller-driven resolution, different paths reach different declarations - which is why a caller renaming its own local can break the helper, something mutation alone cannot do.
  • What does this cost when the helper is unit-tested on its own?
    The test has to reproduce a call chain rather than just supply arguments. The dependency is not in the signature, so a test that calls the helper directly may fail, pass by accident, or exercise a binding no real caller ever supplies.
  • If the value genuinely belongs to the caller, what is the safer shape?
    Make it an explicit parameter, or an explicitly installed named binding with a documented default and a defined extent. Both keep the dependency visible; an ordinary free name resolved from the stack keeps it invisible to everyone on both sides of the call.

saying these in an interview costs you the question

  • Calls it a mutation bug when the declaration itself differs per path.
  • Says the helper's own file must show where the value comes from.
  • Assumes only the immediate caller can supply the binding.
  • Thinks renaming a purely local variable cannot affect anything else.
  • Believes lexical and caller-driven lookup differ only in speed.