Handlers built in a loop all reported the last row, so a copy was added above the loop; why did nothing change?
answer
- the copy moved, the binding did not
- count bindings, not assignments
- the declaration site decides
- inside the body, or a parameter
- identical symptom after the fix
basics
~20 sA variable declared above the loop is one binding reassigned on every pass — the loop variable under a new name. Only a binding created during the pass, by the loop form or by a declaration in the body, differs per closure.
solid answer
~40 sThe fix moved the *assignment* but not the *declaration*. Declaring a name above the loop creates one binding; the copy inside the body then overwrites that single binding on every pass, which is structurally identical to what the loop variable was already doing. All the closures still name one variable, so they still agree, and the symptom is unchanged. The review heuristic that catches this in seconds is to **count bindings, not assignments**: ask how many times the declaration executes, not how many times a value is written. Moving the declaration into the loop body is the whole correction — the capture site and the invocation site stay exactly as they are.
code
pseudocode · 6 linescurrent = nothing // one declaration, one binding
for each row in rows:
current = row // an assignment, not a new binding
handlers.add(function() { show(current) })
// after the loop current holds the last row, and every handler shows itgo deeper
Take away the rule of thumb: a copy only helps if the variable it copies into is declared inside the loop body, so a new one exists on each pass.
Explain the difference between executing an assignment and creating a binding, and show why the hoisted copy is the loop variable under another name.
Demonstrate the diagnosis on a reopened bug: the symptom is unchanged, so prove the binding count rather than re-reading the diff, and leave a test that fails on the shape.
Decide what the team's standard is — names captured inside a loop are declared inside that loop body — and what carries it: a review habit, a lint rule, or a test that ships with the pattern.
## What the fix actually changed It changed the name and nothing else. The broken code had closures naming the loop variable; the "fixed" code has closures naming a variable declared above the loop that the body assigns on every pass. Both shapes have one variable alive for the whole loop, both have it overwritten once per pass, and both leave exactly one surviving value for every closure to read. Only the spelling differs, which is why the bug report reopens with an identical description. This fix keeps being proposed because it *looks* like the right idea. Someone correctly heard "copy the value" and implemented the copy, but the copy's destination is what matters, and the destination is decided by where the name is declared. ## Assignments are not bindings | where the name is declared | bindings created over N passes | what the N closures see | |---|---|---| | above the loop, assigned in the body | 1 | one value, identical for all of them | | the loop variable of a form that reuses one variable | 1 | one value, identical for all of them | | the loop variable of a form that binds per iteration | N | each pass's own value | | a local declared inside the loop body | N | each pass's own value | | a parameter of a function called once per pass | N | each pass's own value | The first two rows are the same shape wearing different names, and the last three are the same shape wearing different names. An assignment writes into a binding that already exists; only a declaration that executes inside the pass brings a new one into being. ## Reviewing for it 1. Find the name the closure's body mentions. 2. Find where that name is **declared** — not where it is assigned, and not where it is read. 3. If the declaration sits outside the loop body, the count of bindings is one and the code is broken however many times the body assigns to it. 4. Check that invocation is genuinely deferred past the loop. If the closures are invoked in the same pass, this code is not broken and something else is. ## Proving it, rather than arguing about it - Reproduce with at least **two** rows and invoke after the loop; a single-row fixture agrees with every version of the code. - Assert that closures built for different rows **disagree**. On the failed fix that assertion still fails, which is the cheapest possible demonstration that the fix did nothing. - Call a closure built on the first pass, run one more pass, call it again. If its answer moved, it is still reading a variable the loop writes. - Leave the test in place once it is green. It is the only thing that stops the same shape reappearing in the next loop somebody writes. ## Why this class of fix keeps coming back - The broken and the fixed shapes differ by the position of a single declaration, which is easy to miss in a diff and easy to move by accident during unrelated cleanup. - The failed fix **reads as careful**: it added a copy, gave it a descriptive name, and touched nothing else. - Hoisting declarations out of loop bodies is a habit some readers carry from other concerns entirely, and here it is exactly the wrong direction. - The symptom after the failed fix is byte-for-byte the previous symptom, so a triage that keys on the symptom concludes the report is a duplicate and closes it. The standard worth setting from this is small and mechanical: when a closure is created inside a loop, the names it mentions should be declared inside that loop body, or be parameters of the function that creates the closure. Anything else needs a reason in the review.
- What is the smallest edit that corrects the failed fix?Move the declaration of the copy inside the loop body so each pass declares its own. The assignment, the closure and the invocation site all stay as they are. If the language requires the declaration and the initialisation together, that is the same edit written as one line.
- How would you stop this regression returning in the next loop someone writes?Keep a test that builds over several rows, invokes after the loop and asserts the closures disagree, and review for the declaration site rather than for the presence of a copy. The second habit generalises: it catches the shape wherever a closure is built inside a loop.
saying these in an interview costs you the question
- Thinks any assignment to a new name creates a fresh binding
- Adds a second copy above the loop and expects a different result
- Believes marking the copy read-only gives each pass its own binding
- Concludes the fix works but the handlers run in the wrong order
- Verifies the fix against a fixture containing a single row