7.3 Scopes and Symbol Tables
The parser can recognize x; it cannot know which declaration x refers to. Name resolution maps declarations and uses to semantic identities. A scope is a region where bindings are visible. A symbol table stores the bindings available in those regions.
Start with a stack of maps. Entering a block pushes a map; leaving pops it. Declaring let x inserts into the current map. Resolving a use searches from innermost map outward:
global: print -> Builtin#1
function: x -> Symbol#12
block: x -> Symbol#19, y -> Symbol#20
use x -> Symbol#19
use print -> Builtin#1This implements lexical scope and shadowing naturally. It is not dynamic scope: a function resolves names from where it was written, not from whoever calls it.
The difference between the two scoping rules is worth tabulating:
| dimension | lexical scope | dynamic scope |
|---|---|---|
| where a name resolves | the position in the code text | the runtime call chain |
| statically determinable | yes, resolvable at compile time | no, depends on runtime |
| result stability | independent of the caller | varies with the caller |
| mainstream usage | nearly all modern languages | rare (early Lisp, some shells) |
This chapter, and most languages, default to lexical scope: the map stack's “search outward” step corresponds exactly to “look in enclosing code.”
Declare in a Deliberate Order
For each scope, decide which declarations are visible early. Functions may be predeclared to support recursion. A variable initializer is often resolved before the new variable enters scope, so let x = x; reports a self-reference error. Another language may permit it, but the resolver must make that policy explicit.
Each symbol should have an ID, name, declaration range, kind, visibility, and later type. Recording raw strings is not resolution: two variables named x can be different symbols.
Scope Errors Need Context
Duplicate declaration means the current scope already has a conflicting name. Shadowing means an outer scope has one. A language may allow the second and reject the first. Point diagnostics at both declarations.
For an undefined name, create an error binding or preserve a failed lookup so later passes do not repeat the same message. Make scope exit structured: an exception or parse failure must not leak one block's bindings into the next.
Hint
Log scope depth, declaration ID, and lookup path for a broken program. x: block -> function -> global often reveals a resolver bug faster than a large map dump.
Resolve a Nested Example Step by Step
let x = 1;
fun f(x) {
{ let y = x; print(y); }
print(x);
}Create the module scope and bind outer x. Predeclare f there. Enter f's scope and bind parameter x to a new symbol, which shadows the outer one. Enter the block, resolve initializer x by finding the parameter, then bind y. print(y) finds block y; the final print(x) finds parameter x. A resolver should record these symbol IDs, not just conclude that every spelling is valid.
Closures add one consequence: a nested function using an outer symbol needs a binding to that symbol even after the outer function returns. Resolution identifies this capture; later runtime design decides how to represent it.
Lookup Rules to Test
- A use before a declaration follows the language's stated policy.
- A same-scope duplicate points to the first declaration.
- An inner shadowed use resolves inward; a use after scope exit resolves outward.
- A captured use records the declaration where it was written, not caller locals.