4.6 KiB
Scopes and Closures: resolution and safety
This page documents how name resolution works with ClosureScope, how to avoid recursion pitfalls, and how to safely capture and execute callbacks that need access to outer locals.
Why this matters
Name lookup across nested scopes and closures can accidentally form recursive resolution paths or hide expected symbols (outer locals, module/global functions). The rules below ensure predictable resolution and prevent infinite recursion.
Resolution order in ClosureScope
When evaluating an identifier name inside a closure, ClosureScope.get(name) resolves in this order:
- Closure frame locals and arguments
- Captured receiver (
closureScope.thisObj) instance/class members - Closure ancestry locals + each frame’s
thisObjmembers (cycle‑safe) - Caller
thismembers - Caller ancestry locals + each frame’s
thisObjmembers (cycle‑safe) - Module pseudo‑symbols (e.g.,
__PACKAGE__) from the nearestModuleScope - Direct module/global fallback (nearest
ModuleScopeand its parent/root scope) - Final fallback: base local/parent lookup for the current frame
This preserves intuitive visibility (locals → captured receiver → closure chain → caller members → caller chain → module/root) while preventing infinite recursion between scope types.
Use raw‑chain helpers for ancestry walks
When authoring new scope types or advanced lookups, avoid calling virtual get while walking parents. Instead, use the non‑dispatch helpers on Scope:
chainLookupIgnoreClosure(name)- Walk raw
parentchain and check only per‑frame locals/bindings/slots. - Ignores overridden
get(e.g., inClosureScope). Cycle‑safe.
- Walk raw
chainLookupWithMembers(name)- Like above, but after locals/bindings it also checks each frame’s
thisObjmembers. - Ignores overridden
get. Cycle‑safe.
- Like above, but after locals/bindings it also checks each frame’s
baseGetIgnoreClosure(name)- For the current frame only: check locals/bindings, then walk raw parents (locals/bindings), then fallback to this frame’s
thisObjmembers.
- For the current frame only: check locals/bindings, then walk raw parents (locals/bindings), then fallback to this frame’s
These helpers avoid ping‑pong recursion and make structural cycles harmless (lookups terminate).
Preventing structural cycles
- Don’t construct parent chains that can point back to a descendant.
- A debug‑time guard throws if assigning a parent would create a cycle; keep it enabled for development builds.
- Even with a cycle, chain helpers break out via a small
visitedset keyed byframeId.
Capturing lexical environments for callbacks
For dynamic objects or custom builders, capture the creator’s lexical scope so callbacks can see outer locals/parameters:
- Use
snapshotForClosure()on the caller scope to capture locals/bindings/slots and parent. - Store this snapshot and run callbacks under
ClosureScope(callScope, captured).
Kotlin sketch:
val captured = scope.snapshotForClosure()
val execScope = ClosureScope(currentCallScope, captured)
callback.execute(execScope)
This ensures expressions like contractName used inside dynamic get { name -> ... } resolve to outer variables defined at the creation site.
Closures in coroutines (launch/flow)
- The closure frame still prioritizes its own locals/args.
- Outer locals declared before suspension points remain visible through slot‑aware ancestry lookups.
- Global functions like
delay(ms)andyield()are resolved via module/root fallbacks from within closures.
Tip: If a closure unexpectedly cannot see an outer local, check whether an intermediate runtime helper introduced an extra call frame; the built‑in lookup already traverses caller ancestry, so prefer the standard helpers rather than custom dispatch.
Local variable references and missing symbols
- Unqualified identifier resolution first prefers locals/bindings/slots before falling back to
thismembers. - If neither locals nor members contain the symbol, missing field lookups map to
SymbolNotFound(compatibility alias forSymbolNotDefinedException).
Performance notes
- The
visitedsets used for cycle detection are tiny and short‑lived; in typical scripts the overhead is negligible. - If profiling shows hotspots, consider limiting ancestry depth in your custom helpers or using small fixed arrays instead of hash sets—only for extremely hot code paths.
Dos and Don’ts
- Do use
chainLookupIgnoreClosure/chainLookupWithMembersfor ancestry traversals. - Do maintain the resolution order above for predictable behavior.
- Don’t call virtual
getwhile walking parents; it risks recursion across scope types. - Don’t attach instance scopes to transient/pool frames; bind to a stable parent scope instead.