v3 Semantics: Nested Functions

Work through examples, try the reasoning yourself, then put it into practice.

View Slides

Before you start

  • Trace v2 scope lookup and v3 postfix ASTs.

What you will practice

  • Resolve nested-function references through lexical scopes.
  • Distinguish known declaration arity from dynamically stored callable values.
  • Preserve const and declaration-order rules without inventing type inference.
In this lesson

Lesson

3 min read

v3 Semantics: Nested Functions

The v3 analyzer still returns ordered diagnostic strings. It does not construct closure objects or attach a full capture list. It checks that the names used in a nested function can resolve in the surrounding lexical scopes; the compiler and VM later implement capture and lifetime.

Worked reconstruction

Analyze let base=3; fn make(x){ fn add(y){ return base+x+y; } return add; }. Register base, then register make before traversing its body. Push a function scope with parameter x. Register add in that scope before entering its body; inside add, y resolves locally, x resolves in make's scope, and base resolves in the outer scope. Return to make's scope, resolve add as its return value, then pop the function scope.

Moving base's declaration after make changes the result: base is unavailable when make's body is analyzed. There is no global hoisting pass. Self-recursion is allowed because the current function binding is installed before its body. Function bindings are const-like; assigning to their name is rejected.

A callable value is not always a declared function

For a direct identifier bound to a known function declaration, check arity. For let f=make(2); f(3);, f is a variable binding that may hold a closure. The analyzer must not apply the old v2 rule that every variable callee is a non-function. It checks the name and argument expressions; the VM decides whether the actual value is callable and has the expected arity.

Consequently let f=3; f(); can pass this analyzer and fail at runtime. missing() still fails analysis. A call with a compound callee such as xs[0]() also requires recursively checking the callee expression's names.

Container mutation versus binding mutation

const a=[1]; a[0]=2; updates the referenced object without rebinding a, so it is allowed. a=[2]; attempts to replace a const binding and is rejected. Analyze index targets and RHS expressions, but array bounds, key hashability, and operand types remain runtime checks in this version. A const binding is not a promise of deep immutability.

Debugging lab

An analyzer rejects every f() where f was introduced with let, yet accepts const a=[1]; a=[2];. Explain why its call policy is too strict and its assignment policy too weak.

Compare your diagnosis after attempting the lab

v3 variables can hold closure values, so their callability is a dynamic check. Reassigning a const identifier remains forbidden; only mutations through a contained index are allowed without rebinding.

Transfer task

Write one nested function whose outer reference is valid, then move the declaration past the use to produce a name error. Compare direct known-function wrong arity with wrong arity through a variable holding that function.

Carry forward

Carry a name-valid AST into closure compilation. Record which decisions remain dynamic so later runtime failures are not misdiagnosed as missing static checks.

Build semantic-analysis-v3. Keep a before/after trace of one failing test. Use the checkpoint to diagnose specific skills; passing it does not replace implementing and testing the artifact.


Practice sequence

Use the order below for a guided path, or choose an item to revisit. A pass records a successful attempt; try a fresh example later to check what you can reconstruct.

Join Discord