Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

11. Bare assignment, lexical block scope, nearest-binding rule

  • Status: Accepted
  • Date: 2026-09-02

Context

How are variables introduced and where do they live? Options for introduction: a declaration keyword (let/var/const), or bare assignment (x = 1 both creates and updates). Options for scope: function scope with hoisting (old JavaScript), block scope, or dynamic scope.

The project owner chose bare x = 5 — no let/var. That settles introduction and forces a decision about what a bare assignment means when an outer scope already has that name.

Decision

  • x = value is the only binding form. No declaration keyword.
  • Scope is lexical, and only functions introduce a scope. The global level is one scope; each function body (with its parameters) is a scope. if / elif / else / while / for blocks do not — a name first assigned inside one of them stays visible after it, in the enclosing function or global scope.
  • Nearest-binding rule: evaluating x = value first looks for an existing binding of x, searching the current function scope then each enclosing function scope out to the global scope.
    • Found ⇒ that binding is updated, wherever it lives.
    • Not found ⇒ a new binding is created in the current scope.
  • Reading x uses the same nearest-binding search; an unresolved read is E0301.
  • A for loop variable is an ordinary binding in the enclosing scope; after the loop it holds the last value iterated.
  • Closures capture by reference to the binding, so a closure can mutate a variable from an enclosing function.

There are no global / nonlocal keywords — the nearest-binding rule makes "assign to the outer x" the default when an outer x exists.

Consequences

  • No keyword, no "declared but unused" ceremony, closures-that-mutate just work.
  • Function-only scoping matches Python and most scripting languages, so it holds no surprises: if cond:\n result = ... followed by print(result) works.
  • You cannot shadow an outer variable by assigning the same name in an inner block — the inner assignment updates the outer one. To get a fresh variable, use a fresh name. This is the deliberate "one obvious way" tradeoff and the opposite of Python's local-by-default rule.
  • A typo in an assignment target that happens to match an outer name silently mutates that name. The resolver can warn on suspicious cases later; not in M1.
  • Closures over a for variable all see its final value (the well-known Python behaviour), because the variable is one binding, not one per iteration. If per-iteration capture is wanted, wrap the body in an immediately-called function. This may get a dedicated fix later.
  • A future bytecode VM will want per-use slot/depth resolution; the resolver pass is where that goes when the time comes (ADR 0004).

Amendment (2026-09-04): resolution is lexical

Milestone 3 found that the nearest-binding rule as implemented here decides at run time whether an assignment targets a local or an enclosing binding, so the same line in the same function can mean different things on different calls. That cannot be compiled to a slot. ADR 0022 narrows this ADR: the rule itself is unchanged, but "is there an enclosing binding of this name?" is now answered from the enclosing program text rather than from the live scope chain. It also lands the slot resolution this ADR anticipated in the compiler rather than in the resolver pass, which stays a validation-only pass.

Alternatives considered

  • Python's rule (assignment always creates a local unless global/nonlocal). Enables shadowing but needs two extra keywords to write to an outer scope, and the UnboundLocalError surprise is a classic beginner trap.
  • let for new bindings, = for update (Swift-ish). Clear, but the owner explicitly wants no declaration keyword, and it's two forms for one idea.
  • Block scope for if / while / for bodies. Cleaner in theory (a loop temp does not outlive the loop), but it makes the extremely common "compute a value in a branch, use it afterwards" pattern fail, which is a worse surprise than the one it prevents. Rejected.
  • Function scope with hoisting. Rejected outright — a known mistake.