Skip to content

Step 1 of 3 · Reading · ~3 min

Static Variable Resolution

Closures & Classes

Resolving & Binding Variables

Dynamic environment-chain lookup (walk enclosing until you find the name) is correct in the common case, but it has a subtle bug that only shows up with closures and blocks together. This lesson adds a resolver: a static analysis pass that runs after parsing and before interpreting, which pins down exactly which scope each variable reference belongs to.

The bug static resolution fixes

Consider:

var a = "global";
{
  fun showA() { print a; }
  showA();          // "global"
  var a = "block";
  showA();          // should still print "global"!
}

With pure dynamic lookup, showA's closure is the block environment. The second call to showA() walks up from the (now updated) block environment and finds the new a = "block" — because by the time it looks up a, the block environment already has a fresh binding for it. But lexically, showA was defined and its body written before the second a existed, so it should keep resolving to the outer, global a. Real languages guarantee a variable expression always resolves to the same declaration, no matter when it's evaluated. Fixing this requires knowing, at the point each variable is used, exactly how many scopes out to look — decided once, from the static structure of the code, not by whatever happens to exist in the environment at run time.

What the resolver does

The resolver is a tree-walker just like the interpreter, but instead of executing code it tracks scopes. It keeps a stack of dictionaries — one per lexical scope — mapping variable names to a ready/not-ready flag:

python

The declare/define split is exactly what catches var a = a; — while resolving the initializer expression, a exists in the current scope but is marked False (not ready), so a variable expression referencing it there is a compile-time error: "Can't read local variable in its own initializer."

Wiring the distance into the interpreter

resolve_local calls interpreter.resolve(expr, distance), which stores distance in a side table keyed by the AST node itself (e.g. a dict {id(expr): distance}). At evaluation time, instead of environment.get(name) walking the chain link-by-link comparing names, the interpreter does:

python

where get_at(distance, name) just follows enclosing exactly distance times and reads directly from that environment's dictionary — no per-name searching, and critically, the scope it lands on is fixed by the code's static structure, not by what's been declared by the time it happens to run.

Edge cases to watch

  • Function bodies get their own scope nesting for parameters, separate from the scope the function was declared in — the resolver has to open a new scope for each function it visits, resolve the body, then close it.
  • Variables not found in any enclosing local scope are assumed global and resolved dynamically at runtime as before — the resolver only needs to special-case locals.
  • The resolver must visit every node type your interpreter does (blocks, if, while, function declarations/calls, logical, class bodies) — any node visited by the interpreter but skipped by the resolver will silently keep the old buggy dynamic-lookup behavior for that part of the tree.
Up nextClasses & InstancesClosures & Classes

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…