Lesson 5.1 — Scoping disciplines: static and dynamic scope, shadowing, hoisting and the temporal dead zone¶
Techniques: static (lexical) scoping with block, function and module scope and shadowing (ALGOL 60, Naur et al. 1960/1963; Scheme, Sussman & Steele 1975), dynamic scoping with deep and shallow binding (LISP 1.5, McCarthy et al. 1962; the funarg problem, Moses 1970), hoisting and the temporal dead zone (JavaScript
varand ES2015let; Python's function-level locals) · Pebble implements: static block scoping with "visible from the next statement" and unrestricted shadowing (pebble-spec §6.3; exercise E1) · Prerequisites: Lesson 4.7 (the AST you resolve) · Time: 3 hours
A name in a program means nothing by itself: x is whichever declaration of x the language's scoping discipline selects. Two answers have been given since the 1960s. Static scoping picks the declaration from the program text, looking outwards through the nested blocks around the use; dynamic scoping picks the most recent declaration still alive when the use runs, looking back through the calls that led there. Every language in this course's toolchain chose static scoping, but dynamic scoping is alive in shells, Perl's local, Emacs Lisp and TeX, and the rules for where inside a block a declaration starts to count (hoisting, the temporal dead zone, "from the next statement") still differ from language to language. This lesson defines the disciplines precisely, traces them on one program, and proves the property that makes static scoping the default: a name's meaning survives renaming.
1. Problem and motivation¶
The problem. Input: a program tree in which some identifier occurrences are declarations (binding occurrences) and the others are uses (applied occurrences). Output: for every use, the declaration it denotes, or "unbound". The answer must be defined by the language (so every compiler agrees), cheap to compute, and stable under the edits programmers make every day (renaming a local variable, moving a function). In pebblec this is the first job of resolveNames (pebble-spec §6.3), and every later phase — type checking (Ch 6), definite assignment (Lesson 5.7), lowering to PIR (Ch 11) — reads the binding it records in NameExpr::getDecl().
Static scoping¶
The ALGOL 60 report [Nau63, §4.1.3, §5] introduced block structure: a begin … end block may declare identifiers, those declarations are "local" to the block, and an identifier used in a block refers to the declaration of the innermost enclosing block that declares it. The meaning of a use is therefore fixed by the program text: a compiler can resolve every name once, before the program runs. Scheme [SS75] brought the same discipline to Lisp and showed that it also gives closures a clean semantics (a function value carries the environment of its definition). C, Java, Rust, Swift and Pebble are statically scoped; they differ only in what counts as a scope — a block (C99, Java, Rust, Pebble), a whole function (JavaScript var, Python), or a module (Python globals, Rust items).
Dynamic scoping¶
LISP 1.5 [McC62] kept variable bindings on an association list that grows as functions are called: a free variable in a function body is looked up in whatever bindings the callers made, most recent first. This is dynamic scoping. It is trivial to implement in an interpreter, and it makes some things convenient (a caller can temporarily change a "global" setting such as an output radix for everything it calls). Its cost was named the funarg problem by Moses [Mos70]: when a function is passed as an argument or returned, its free variables are resolved in the environment of whoever eventually calls it, not where it was written, so the same function body can mean different things on different calls. Lisps moved to static scoping (Scheme, Common Lisp, Emacs Lisp since version 24 with lexical-binding), keeping dynamic binding only for explicitly declared special variables. Bash functions and Perl's local are still dynamically scoped (§7).
Hoisting and the temporal dead zone¶
Static scoping says which block a declaration belongs to; a language still has to say from where in the block it is visible. Three answers are in use. Declaration-point visibility: visible from the declaration (or, in Pebble, from the next statement) to the end of the block, so a use before the declaration refers to an outer binding (C, Rust, Pebble). Hoisting: the declaration's scope is the whole block or function, as if it had been moved to the top; JavaScript's var and function declarations are hoisted, and a var read before its initializer yields undefined [ECMA24, §14.3.2]. Temporal dead zone (TDZ): the scope is the whole block, but any access before the declaration has executed is a run-time error; ES2015 introduced it for let, const and class [ECMA24, §14.3.1], and Python behaves the same way for function locals (a name assigned anywhere in a function is local throughout it, and reading it before the assignment raises UnboundLocalError [PEP227]). Pebble chose declaration-point visibility with the initializer excluded (let x = x + 1; reads the outer x, pebble-spec §6.3), which makes resolution a single left-to-right walk.
2. Definitions and algorithms¶
Throughout, a program is a tree whose nodes include blocks (function bodies, { … } blocks, the module). Every identifier occurrence is either a declaration \(d\) or a use \(u\), and \(\mathrm{name}(\cdot)\) is its spelling. Blocks form a tree under nesting; for a node \(n\), \(\mathrm{blk}(n)\) is the innermost block containing \(n\), and \(B_1 \sqsupseteq B_2\) means block \(B_1\) encloses (or is) \(B_2\).
Definition 5.1.1 (Region and program position)
The statements of a block are totally ordered by their position in the source. For a declaration \(d\) with \(B = \mathrm{blk}(d)\), the region of \(d\) is the set of program points inside \(B\). A use \(u\) follows \(d\), written \(d \prec u\), if \(u\) lies in \(d\)'s region and the statement of \(B\) that contains \(u\) comes strictly after the statement that contains \(d\). (For Pebble's let x = e;, the initializer \(e\) is in the same statement as \(d\), so no use in \(e\) follows \(d\).)
Definition 5.1.2 (Static resolution under declaration-point visibility)
The candidates of a use \(u\) are \(C(u) = \{\, d \mid \mathrm{name}(d) = \mathrm{name}(u) \land d \prec u \,\}\). If \(C(u) = \emptyset\), \(u\) is unbound. Otherwise the static binding \(\mathrm{res}_s(u)\) is the candidate whose block is innermost (every candidate's block encloses \(\mathrm{blk}(u)\), so the blocks of \(C(u)\) are totally ordered by \(\sqsupseteq\)), and, among candidates of that same block, the last one in source order. A candidate \(d' \ne \mathrm{res}_s(u)\) is shadowed at \(u\) by \(\mathrm{res}_s(u)\).
Static resolution on the running example
In the program of §3, the use u2 inside the inner block of f has candidates \(\{d_1, d_2\}\): \(d_1\) in the module block, \(d_2\) in f's body. f's body is innermost, so \(\mathrm{res}_s(u_2) = d_2\), and \(d_1\) is shadowed there. \(d_3\) is not a candidate: it is in the same block but does not precede u2.
Definition 5.1.3 (Scope)
The scope of a declaration \(d\) is \(\mathrm{scope}(d) = \{\, u \mid \mathrm{res}_s(u) = d \,\}\): its region minus what precedes it and minus the holes where an inner or later declaration of the same name shadows it.
Definition 5.1.4 (Execution, activation and dynamic binding)
Let a program run from its entry. At each moment the live bindings form a sequence \(\langle (n_1, d_1), \dots, (n_k, d_k) \rangle\) ordered by creation time: executing a declaration \(d\) appends \((\mathrm{name}(d), d)\); leaving a block (or returning from a function body) removes the bindings created in it. The dynamic binding of a use \(u\) at the moment it executes is \(\mathrm{res}_d(u) = d_j\) for the largest \(j\) with \(n_j = \mathrm{name}(u)\), or "unbound" if there is none. Unlike \(\mathrm{res}_s\), it depends on the execution: one use executed twice may get two bindings.
Definition 5.1.5 (Hoisted scope and temporal dead zone)
Under hoisting, \(d\)'s candidates are redefined as \(C_h(u) = \{\, d \mid \mathrm{name}(d) = \mathrm{name}(u) \land u \text{ lies in the region of } d \,\}\) (position ignored), and \(\mathrm{res}_h(u)\) is chosen from \(C_h(u)\) as in Definition 5.1.2. A use \(u\) with \(\mathrm{res}_h(u) = d\) is in \(d\)'s temporal dead zone if, when \(u\) executes, the declaration \(d\) (of the current activation of its block) has not executed yet. A TDZ language reports an error at such a use; a hoisting language with default initialization (JavaScript var) yields a default value instead.
The algorithms below compute these definitions. The first is the definition read literally (Lesson 5.2 gives the efficient table-based versions); the second and third are the two classic implementations of dynamic scoping.
Algorithm 5.1.6 (Static resolution by walking outwards)
- Input: a program tree; a use \(u\).
- Output: \(\mathrm{res}_s(u)\) or "unbound".
- Precondition: every node knows its parent and its position in its block.
- Postcondition: the result satisfies Definition 5.1.2.
- Invariant: before examining block \(B\), no block strictly inside \(B\) on the path from \(\mathrm{blk}(u)\) contains a candidate.
Algorithm 5.1.7 (Dynamic scoping by deep binding)
- Input: an interpreter state with an environment stack \(E\) of \((\mathrm{name}, d)\) pairs.
- Output: at each use \(u\), \(\mathrm{res}_d(u)\).
- Precondition: \(E\) holds exactly the live bindings of Definition 5.1.4, oldest at the bottom.
- Postcondition: each lookup returns the most recent live binding of the name.
- Invariant: entering a block records the height of \(E\); leaving it truncates \(E\) to that height.
Algorithm 5.1.8 (Dynamic scoping by shallow binding)
- Input: as in Algorithm 5.1.7.
- Output: as in Algorithm 5.1.7.
- Precondition: a table \(T\) maps each name to its current binding; a save stack \(S\) holds \((\mathrm{name}, \text{old binding})\) pairs.
- Postcondition: \(T[n] = \mathrm{res}_d(u)\) for every use \(u\) of \(n\) as it executes.
- Invariant: for every name \(n\), \(T[n]\) is the most recent live binding of \(n\), and \(S\) holds, for every live binding that is not the most recent of its name, the binding it hid.
3. Worked example¶
The running example of this chapter's first two lessons is a C-like program in the notation of the drill resolve-scopes (declarations are numbered \(d_1, d_2, \dots\) and uses \(u_1, u_2, \dots\) in source order):
var x; // d1 (global)
fn g() {
print(x); // u1
}
fn f() {
var x; // d2
{
print(x); // u2
var x; // d3
print(x); // u3
}
print(x); // u4
g();
}
fn main() {
print(x); // u5
f();
}
flowchart TD
M([module: d1 x]) --> G[fn g: u1]
M --> F[fn f: d2 x, u4, call g]
F --> FB[block: u2, d3 x, u3]
M --> MN[fn main: u5, call f]
Static scoping — Algorithm 5.1.6 for each use, shown as the scope stack that a left-to-right walk maintains (Lesson 5.2 turns this into Algorithm 5.2.2; the table is the drill's trace):
| step | event | scope stack (bottom ▸ top) |
|---|---|---|
| 1 | enter fn g | {x:d1} ▸ {} |
| 2 | u1: look up x → d1 | {x:d1} ▸ {} |
| 3 | enter fn f | {x:d1} ▸ {} |
| 4 | declare x = d2 | {x:d1} ▸ |
| 5 | enter block | {x:d1} ▸ {x:d2} ▸ {} |
| 6 | u2: look up x → d2 | {x:d1} ▸ {x:d2} ▸ {} |
| 7 | declare x = d3 | {x:d1} ▸ {x:d2} ▸ {x:d3} |
| 8 | u3: look up x → d3 | {x:d1} ▸ {x:d2} ▸ {x:d3} |
| 9 | exit block | {x:d1} ▸ |
| 10 | u4: look up x → d2 | {x:d1} ▸ |
| 11 | enter fn main | {x:d1} ▸ {} |
| 12 | u5: look up x → d1 | {x:d1} ▸ {} |
- Step 6:
u2precedesd3in the same block, so \(d_3\) is not yet in the table; the walk finds \(d_2\) one level out. - Step 8: \(d_3\) shadows \(d_2\); step 9 pops it, so
u4sees \(d_2\) again. - Step 2:
g's body is resolved against the module only:f's \(d_2\) is not ing's text.
Dynamic scoping — Algorithm 5.1.7, executing from main:
| step | event | environment stack \(E\) (bottom → top) |
|---|---|---|
| 1 | main: u5 reads x → d1 | x:d1 |
| 2 | main: call f | x:d1 |
| 3 | f: push x = d2 | x:d1 x:d2 |
| 4 | f: u2 reads x → d2 | x:d1 x:d2 |
| 5 | f: push x = d3 | x:d1 x:d2 x:d3 |
| 6 | f: u3 reads x → d3 | x:d1 x:d2 x:d3 |
| 7 | f: end of block, pop x:d3 | x:d1 x:d2 |
| 8 | f: u4 reads x → d2 | x:d1 x:d2 |
| 9 | f: call g | x:d1 x:d2 |
| 10 | g: u1 reads x → d2 | x:d1 x:d2 |
| 11 | f: return from g | x:d1 x:d2 |
| 12 | f: end of block, pop x:d2 | x:d1 |
| 13 | main: return from f | x:d1 |
The two disciplines agree on u2–u5 and disagree on u1: statically \(d_1\) (the global, the only x in g's text), dynamically \(d_2\) (the x that f made before calling g). Shallow binding (Algorithm 5.1.8) gives the same answers: at step 10 its table holds \(T[x] = d_2\) and its save stack holds \((x, d_1)\).
Hoisting — if the same program hoisted declarations to the top of their block (Definition 5.1.5), u2 would resolve to \(d_3\) (same block, position ignored), and u2 would run before var x; // d3 executes: a TDZ language reports an error at u2, a var-hoisting language reads the default value. Pebble's rule gives \(d_2\) with no run-time check.
Try it
./course drill resolve-scopes --seed 289 --difficulty hard --solution traces both disciplines on a generated program; --difficulty easy drills static scoping in nested blocks.
4. Invariants and correctness¶
Static scoping¶
Lemma 5.1.9 (Walking outwards finds the innermost, latest candidate)
Algorithm 5.1.6 returns \(\mathrm{res}_s(u)\) when \(C(u) \ne \emptyset\) and "unbound" otherwise.
Proof
Every candidate \(d \in C(u)\) satisfies \(d \prec u\), so \(\mathrm{blk}(d)\) encloses \(\mathrm{blk}(u)\) and \(d\)'s statement precedes the statement of \(\mathrm{blk}(d)\) that contains \(u\). The loop visits exactly the blocks enclosing \(\mathrm{blk}(u)\), innermost first, and in each block exactly the statements before the one containing \(u\), last first. Invariant (initialization): no block is inside \(\mathrm{blk}(u)\) on the path, so it holds vacuously. Maintenance: the algorithm leaves block \(B\) only after scanning every statement of \(B\) before \(s\) without finding \(\mathrm{name}(u)\), so \(B\) contains no candidate; the next block is \(B\)'s parent. Termination: the block tree is finite and each iteration moves one level out. When the scan finds a declaration \(t\) of \(\mathrm{name}(u)\) in \(B\), \(t \in C(u)\) (it precedes \(u\)'s statement in \(B\)); by the invariant no candidate is in a block strictly inside \(B\), and \(t\) is the last candidate in \(B\) because the scan goes from last to first. That is Definition 5.1.2. If the outermost block is passed without a hit, \(C(u) = \emptyset\).
Theorem 5.1.10 (Static bindings survive consistent renaming)
Let \(d\) be a declaration and \(y\) a name that occurs nowhere in the program. Rename \(d\) and every use in \(\mathrm{scope}(d)\) to \(y\). Then every use has the same static binding in the new program as in the old one (identifying declarations by their position).
Proof
Write \(P\) for the old program and \(P'\) for the new one. Uses in \(\mathrm{scope}(d)\). In \(P'\) their name is \(y\), and the only declaration named \(y\) is \(d\); \(d \prec u\) still holds (positions did not change), so \(C'(u) = \{d\}\) and \(\mathrm{res}'_s(u) = d\). Uses named \(\mathrm{name}(d)\) outside \(\mathrm{scope}(d)\). Their candidate set in \(P'\) is \(C(u) \setminus \{d\}\). Since \(\mathrm{res}_s(u) \ne d\) in \(P\), removing \(d\) from the candidates cannot change the innermost-then-latest choice (the choice is the maximum of a total order, and a non-maximal element was removed); and if \(d\) was the only candidate, then \(u \in \mathrm{scope}(d)\), a contradiction. All other uses. Their names and candidate sets are unchanged, because \(y\) is fresh. So \(\mathrm{res}'_s = \mathrm{res}_s\) everywhere.
Renaming breaks dynamic scoping
Rename \(d_2\) (and its scope u2, u4) to y in the running example. Statically nothing changes. Dynamically, u1 in g now finds \(d_1\) instead of \(d_2\): the meaning of g's body depended on a local name inside f. This is the funarg problem [Mos70] in its simplest form, and why Theorem 5.1.10 has no dynamic counterpart.
Dynamic scoping¶
Proposition 5.1.11 (Deep and shallow binding agree)
On every execution, Algorithms 5.1.7 and 5.1.8 return the same binding at every use, namely \(\mathrm{res}_d(u)\) of Definition 5.1.4.
Proof
By induction on the number of events executed, with the hypothesis: \(E\) equals the live-binding sequence, and for every name \(n\), \(T[n]\) is the topmost entry for \(n\) in \(E\) (or unbound), and \(S\) holds, in order, the pair \((n, \text{previous } T[n])\) for each entry of \(E\). Base: all empty. Declaration: \(E\) gets \((n, d)\) on top, so the topmost entry for \(n\) becomes \(d\) = new \(T[n]\), and \(S\) records the old \(T[n]\). Block exit: both algorithms remove the entries created since the mark (the marks are taken at the same event, and \(|S| = |E|\) by the hypothesis); popping \(S\) in reverse restores each \(T[n]\) to the value it had before the corresponding push, which is the topmost remaining entry for \(n\). Use: deep binding returns the topmost entry for \(\mathrm{name}(u)\), shallow binding returns \(T[\mathrm{name}(u)]\); they are equal by the hypothesis and equal \(\mathrm{res}_d(u)\) by Definition 5.1.4.
Hoisting and the temporal dead zone¶
Proposition 5.1.12 (Where hoisting and declaration-point visibility disagree)
For a use \(u\), \(\mathrm{res}_h(u) \ne \mathrm{res}_s(u)\) if and only if some block \(B \sqsupseteq \mathrm{blk}(u)\), at least as inner as the block of \(\mathrm{res}_s(u)\) (any enclosing block, if \(u\) is unbound), contains a declaration of \(\mathrm{name}(u)\) in a statement at or after the one containing \(u\). In that case \(d = \mathrm{res}_h(u)\) is such a declaration, and, if no function or closure body lies between \(B\) and \(u\) (so that \(u\) can only run while its statement runs), every execution of \(u\) is in \(d\)'s temporal dead zone.
Proof
(\(\Leftarrow\)) Let \(d'\) be such a declaration in block \(B\). Then \(d' \in C_h(u) \setminus C(u)\). If \(B\) is strictly inside the block of \(\mathrm{res}_s(u)\), the innermost choice of \(\mathrm{res}_h\) lands in \(B\) or deeper, never on \(\mathrm{res}_s(u)\). If \(B\) is that block, \(d'\) comes after every element of \(C(u)\) in \(B\) in source order, so the "last in the block" choice is \(d'\) or a later declaration. Either way \(\mathrm{res}_h(u) \ne \mathrm{res}_s(u)\). (\(\Rightarrow\)) If there is no such declaration, \(C_h(u) \setminus C(u)\) contains only declarations in blocks strictly outside the block of \(\mathrm{res}_s(u)\), which never win the innermost choice, so the two resolutions agree. Dead zone: by the case analysis, \(d = \mathrm{res}_h(u)\) lies in a block \(B\) enclosing \(u\), in a statement at or after the statement \(s\) of \(B\) that contains \(u\). The statements of one activation of \(B\) run in source order, and \(d\) executes only when its own statement completes its initializer; so whenever \(u\) executes — by the hypothesis, only while \(s\) runs, possibly many times if \(s\) contains a loop — the current activation of \(B\) has not yet executed \(d\). That is Definition 5.1.5's dead zone. (The hypothesis matters: in JavaScript, { function g() { return w; } let w = 1; g(); } reads w inside g, which runs after the declaration, so no error occurs.)
5. Complexity¶
Let \(n\) be the number of identifier occurrences, \(\delta\) the maximum block nesting depth, \(b\) the maximum number of statements in a block, and, for dynamic scoping, \(h\) the height of the environment stack when a use executes (at most the number of live bindings).
| Technique | Resolve one use | All uses | Space | Pathological input |
|---|---|---|---|---|
| Static, Algorithm 5.1.6 | \(O(\delta\, b)\) | \(O(n\, \delta\, b)\) | the tree | \(\delta\) nested blocks each declaring one other name: \(\Theta(\delta)\) per use of the outermost name |
| Static, table-based (Lesson 5.2) | \(O(1)\) expected | \(O(n)\) expected | \(O(n)\) | — |
| Dynamic, deep binding (Alg. 5.1.7) | \(O(h)\) | per execution | \(O(h)\) | recursion depth \(r\) with a local declared per frame and a global read at the bottom: \(\Theta(r)\) per read |
| Dynamic, shallow binding (Alg. 5.1.8) | \(O(1)\) | per execution | \(O(h)\) save stack | none for lookup; a context switch (coroutine, thread) must swap the whole table |
| Hoisting / TDZ | as static | as static + one run-time check per possibly-dead use | one flag per binding | uses in loops before the declaration keep their check |
Justification. Algorithm 5.1.6 scans at most \(b\) statements in each of at most \(\delta + 1\) blocks. Deep binding scans the environment from the top; on the pathological recursion every frame adds one binding of another name above the global, so a read of the global scans all \(r\). Shallow binding does one table access per event and one save-stack entry per declaration. A TDZ check can be removed statically for uses that are dominated by their declaration (Proposition 5.1.12 says which uses are candidates); V8 emits the check (BuildThrowIfHole) at the first access of a binding in a basic block and elides the later ones (HoleCheckElisionScope) [V8-TDZ], so straight-line code pays at most once per binding.
Real scale: Clang's IdentifierResolver gives \(O(1)\) lookups regardless of \(\delta\) (Lesson 5.2), so even machine-generated C with thousands of nested blocks resolves in linear time.
6. Variants and refinements¶
Static scoping¶
- Function scope instead of block scope (JavaScript
var, Python): every local of a function shares one region; simpler (no block tables) but confusing in loops (all closures created in aforloop share onevar i, the classic JavaScript bug that ES2015's per-iterationletbindings fix [ECMA24, §14.7.4]). - Module and namespace scope (Python's LEGB rule: Local, Enclosing, Global, Builtins [PEP227]; Rust's items): a module is one more block, but it is order-independent (Lesson 5.3).
- Shadowing restrictions. Java forbids a local to shadow another local or parameter of the same method (JLS §6.4 [JLS21-6]); C# forbids reusing a name anywhere in overlapping local declaration spaces; C++ forbids redeclaring a parameter in the outermost block of the function. Pebble allows all shadowing, like Rust. Warnings recover some safety: Clang and GCC
-Wshadow(§7). - Point of declaration. C and C++ make a declaration visible from the end of its declarator, before the initializer, so
int x = x;initializesxwith itself [CPP-Draft, basic.scope.pdecl]; Pebble and Rust exclude the initializer (let x = x + 1reads the outerx).
Dynamic scoping¶
- Special variables (Common Lisp
defvar, Emacs Lispdefvarunderlexical-binding): dynamic binding only for names declared special; everything else is static. Racket'sparameterizeand Scala's implicit/givenparameters are the modern, typed forms of "a caller sets a value for everything it calls". - Deep vs shallow binding (Algorithms 5.1.7 and 5.1.8): deep binding makes lookups slow and context switches free; shallow binding the reverse. Baker's rerooting [Bak78] moves the table between contexts lazily.
Hoisting and the temporal dead zone¶
- Default-initialized hoisting (JavaScript
var): no error, readsundefined; errors surface much later. - TDZ (JavaScript
let/const/class, Python locals, Swift's "use of local variable before its declaration" compile-time error): the error is at the right place. A statically checked TDZ is a special case of definite assignment (Lesson 5.7). - Declaration-point visibility (C, Rust, Pebble): no dead zone exists by construction, at the price that a use before a local declaration silently binds an outer name. Pebble's
resolveNamesadds a note ("declared here, after its use") when that outer lookup fails (Lesson 5.8).
7. In real compilers¶
Static scoping¶
Clang builds a Scope object per block while parsing (clang/include/clang/Sema/Scope.h [CLANG-Scope]) and records each declaration in the IdentifierResolver chain of its name (Lesson 5.2); Sema::CheckShadow in clang/lib/Sema/SemaDecl.cpp [CLANG-SemaDecl] produces -Wshadow. GCC's C front end binds names in c_scope objects (bind and pop_scope in gcc/c/c-decl.cc, gcc-15 [GCC-CDecl]) and warns in warn_if_shadowing. rustc resolves locals with a stack of ribs (struct Rib in compiler/rustc_resolve/src/late.rs, rustc 1.94.1 [RUSTC-Late]). CPython computes function, class and module scopes in a separate pass over the AST (Python/symtable.c, analyze_name [CPY-Symtable]).
Shadowing warnings in Clang 23 and GCC 14
Reproduce (clang 23.1.2, gcc 14.2.0; any OS):
cat > shadow.c <<'EOF'
int x = 1;
int f(int n) {
int x = n;
for (int n = 0; n < 3; n++) {
int x = n * 2;
(void)x;
}
return x;
}
EOF
clang-23 -fsyntax-only -Wshadow-all shadow.c
gcc-14 -fsyntax-only -Wshadow shadow.c
Output (complete; Clang first, then GCC):
shadow.c:3:7: warning: declaration shadows a variable in the global scope [-Wshadow]
3 | int x = n;
| ^
shadow.c:1:5: note: previous declaration is here
1 | int x = 1;
| ^
shadow.c:4:12: warning: declaration shadows a local variable [-Wshadow]
4 | for (int n = 0; n < 3; n++) {
| ^
shadow.c:2:11: note: previous declaration is here
2 | int f(int n) {
| ^
shadow.c:5:9: warning: declaration shadows a local variable [-Wshadow]
5 | int x = n * 2;
| ^
shadow.c:3:7: note: previous declaration is here
3 | int x = n;
| ^
3 warnings generated.
shadow.c: In function 'f':
shadow.c:3:7: warning: declaration of 'x' shadows a global declaration [-Wshadow]
3 | int x = n;
| ^
shadow.c:1:5: note: shadowed declaration is here
1 | int x = 1;
| ^
shadow.c:4:12: warning: declaration of 'n' shadows a parameter [-Wshadow]
4 | for (int n = 0; n < 3; n++) {
| ^
shadow.c:2:11: note: shadowed declaration is here
2 | int f(int n) {
| ~~~~^
shadow.c:5:9: warning: declaration of 'x' shadows a previous local [-Wshadow]
5 | int x = n * 2;
| ^
shadow.c:3:7: note: shadowed declaration is here
3 | int x = n;
| ^
What to notice: three nested regions (global, function body, for body), each warning names the shadowed candidate of Definition 5.1.2 in a note. The warning is exactly "the static binding's block is strictly inside the block of another candidate". Both compilers accept the program: C allows all three shadowings, like Pebble.
Function, class and module scopes in CPython 3.11
Reproduce (CPython 3.11.15):
cat > symt.py <<'EOF'
import symtable
src = """
x = 1
def outer(a):
y = a + x
def inner():
nonlocal y
y += 1
return y
return inner
class C:
z = x
"""
def show(t, depth=0):
syms = ", ".join(
f"{s.get_name()}:" + "/".join(k for k, f in [("param", s.is_parameter), ("local", s.is_local),
("global", s.is_global), ("free", s.is_free),
("nonlocal", s.is_nonlocal)] if f())
for s in sorted(t.get_symbols(), key=lambda s: s.get_name()))
print(" " * depth + f"{t.get_type()} {t.get_name()}: {syms}")
for c in t.get_children():
show(c, depth + 1)
show(symtable.symtable(src, "<demo>", "exec"))
EOF
python3.11 symt.py
Output (complete):
module top: C:local/global, outer:local/global, x:local/global
function outer: a:param/local, inner:local, x:global, y:local
function inner: y:free/nonlocal
class C: x:global, z:local
What to notice: Python's regions are whole functions, classes and the module (no block scope). x is resolved statically to the module (global) from both outer and C; inner's y is free: it is bound in the enclosing function's region, which is static scoping across function boundaries (the closure captures outer's y).
Dynamic scoping¶
No mainstream compiled language scopes ordinary variables dynamically. Interpreters do: Bash functions see their callers' local variables (the Bash manual, "Shell Functions" [BASH-Manual]); Perl's local saves and restores a package variable exactly as Algorithm 5.1.8 does, while my is lexical. Emacs Lisp files without lexical-binding: t still use dynamic binding for every variable.
Dynamic scoping in Bash 5.2 and Perl 5.38
Reproduce (GNU bash 5.2.21, perl 5.38.2):
cat > dyn.sh <<'EOF'
x=global
g() { echo "g sees x=$x"; }
f() { local x=f-local; g; }
f
g
EOF
bash dyn.sh
cat > dyn.pl <<'EOF'
our $x = "global";
sub g { print "g sees $x\n" }
sub f_local { local $x = "dynamic"; g() }
sub f_my { my $x = "lexical"; g() }
f_local(); f_my(); g();
EOF
perl dyn.pl
Output (complete):
What to notice: the same body of g reads two different bindings depending on its caller: Definition 5.1.4, and the u1 row of §3. Perl makes the choice per declaration: local is dynamic (shallow binding, Algorithm 5.1.8, restored when f_local returns), my is static, so f_my's variable is invisible to g.
Hoisting and the temporal dead zone¶
V8 implements let/const hoisting with a "hole" value checked on access (src/interpreter/bytecode-generator.cc, BytecodeGenerator::BuildThrowIfHole, V8 13.4.1 [V8-TDZ]); CPython compiles reads of a function local to LOAD_FAST, which raises UnboundLocalError if the slot is empty (the local was decided by symtable.c for the whole function).
Hoisting and the TDZ in Node.js 22 and CPython 3.11
Reproduce (node v22.22.2, CPython 3.11.15):
cat > tdz.js <<'EOF'
function f() {
console.log(typeof v, v); // var is hoisted: declared, undefined
var v = 1;
try { console.log(w); } catch (e) { console.log(e.constructor.name + ": " + e.message); }
let w = 2; // let is hoisted too, but in its temporal dead zone
console.log(g()); // function declarations are hoisted with their body
function g() { return "g"; }
}
f();
EOF
node tdz.js
cat > unbound.py <<'EOF'
x = "global"
def f():
print(x) # x is local to f (assigned below), so this read fails
x = "local"
try:
f()
except UnboundLocalError as e:
print(type(e).__name__ + ":", e)
EOF
python3.11 unbound.py
Output (complete):
undefined undefined
ReferenceError: Cannot access 'w' before initialization
g
UnboundLocalError: cannot access local variable 'x' where it is not associated with a value
What to notice: all three JavaScript forms are hoisted (Definition 5.1.5); they differ only in what happens in the dead zone: var reads undefined, let throws, a function declaration is already initialized. Python's print(x) does not fall back to the global, although the local assignment comes later: the local's region is the whole function, so the use is in its dead zone (Proposition 5.1.12). Under Pebble's rule the same program would print global.
8. Comparison¶
| Technique | Power / precision | Speed (asymptotic · practical) | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Static scoping | Meaning fixed by the text; bindings survive renaming (Theorem 5.1.10); closures well defined | \(O(1)\) per use with a table (Lesson 5.2) · resolved once at compile time | Errors at compile time, at the use; shadowing warnings possible | Low: one walk with a scope table | C, C++, Java, Rust, Swift, Python, JavaScript, Pebble |
| Dynamic scoping | Meaning depends on the call path; renaming a caller's local changes a callee (funarg problem) | \(O(h)\) deep, \(O(1)\) shallow · per execution, never ahead of time | Errors only at run time, far from the cause | Lowest in an interpreter; no compile-time resolution possible | Shells, Perl local, special variables, parameterize |
| Hoisting and the temporal dead zone | Same bindings as static scoping inside whole-block regions; dead-zone uses detected (TDZ) or silently default (var) |
As static, plus a run-time check per possibly-dead use (removable when dominated) | TDZ: a precise run-time error; var: none at all |
Low; the check is a flag per binding | JavaScript, Python function locals, Swift locals (at compile time) |
Choose static scoping for any compiled language: it is the only one of the three that a compiler can resolve completely before run time, and the only one under which a function means what its text says. Choose dynamic scoping only for explicitly marked context parameters (a logging level, an output port), and give it its own syntax so readers can see it. Choose declaration-point visibility (Pebble, Rust, C) when you want resolution in one left-to-right pass with no run-time checks; choose hoisting with a statically checked dead zone when you want mutually recursive local functions (Swift allows local functions to be used before their declaration in the same scope) and are prepared to run a definite-assignment analysis (Lesson 5.7).
9. Assessment¶
| Technique | Quiz ids | Drill | Flashcard tag | Exercises |
|---|---|---|---|---|
| Static scoping | static-binding-trace, shadow-candidates |
resolve-scopes (all levels) |
static-scope |
E1 |
| Dynamic scoping | dynamic-binding-trace, deep-vs-shallow |
resolve-scopes --difficulty hard |
dynamic-scope |
— |
| Hoisting and the temporal dead zone | hoisting-tdz-js, python-unbound |
resolve-scopes (compare the declaration-point rule) |
hoisting-tdz |
E1 (the "declared later" note, E4) |
References¶
See the chapter references.