Closures & Upvalues in a Bytecode VM
Writing a Compiler/Interpreter: Advanced
Chapter 7 · Closures & Upvalues in a Bytecode VM
Course 1's own tree-walking interpreter got closures for free — a WispFunction just kept a reference to the Environment active when it was declared, and Python's own garbage collector kept that object alive for as long as anything still pointed at it. This course's own VM has no such luxury. Chapter 4 made locals live directly on the shared value stack, and Chapter 6's own OP_RETURN deletes a function's locals the instant it returns. If an inner function captured one of those locals, what happens to it?
The Problem, Concretely
By the time counter() is called the first time, makeCounter has already returned. Its own call frame is gone. Per Chapter 6's own OP_RETURN, count's stack slot was deleted along with everything else makeCounter ever pushed. But increment's own body still says count = count + 1 — referring to a slot that, by any of the last six chapters' own rules, no longer exists.
Upvalues: an Indirection That Can Outlive Its Own Slot
The fix is an object that starts by pointing at the live stack slot, and later — the instant that slot is actually about to be destroyed — switches to holding its own private copy instead. Every read or write goes through this same object either way; the closure using it never has to know which state it's in.
A function value now needs to carry its own captured upvalues alongside it, so WispFunction (the compiled code, shared across every call) is wrapped by a new Closure object (created fresh every time the fun statement actually runs, holding that particular call's own captured upvalues).
Compile-Time: Resolving a Name Across Function Boundaries
Chapter 6's visit_variable already checked "is this a local?" before falling back to a global. This chapter inserts a third possibility in between: "is this a local of an enclosing function?" Each Compiler now keeps a reference to the compiler for the function it's nested inside.
The is_local flag distinguishes two genuinely different situations: capturing a variable that's a plain local one level up (grab a stack slot directly), versus a variable an enclosing function itself already had to capture as an upvalue from somewhere further out (just forward that same upvalue along, unchanged). visit_variable/visit_assign now check resolve_local first, then resolve_upvalue, and only fall back to a global if neither finds anything — new opcodes OP_GET_UPVALUE/OP_SET_UPVALUE for the middle case.
OP_CLOSURE: Building the Real Runtime Object
A function statement no longer just pushes a bare WispFunction constant — it emits OP_CLOSURE, followed by one (is_local, index) pair per upvalue the function's own compiler discovered it needed.
capture_upvalue checks whether an Upvalue already exists for that exact stack slot before creating a new one — necessary so that two closures capturing the same variable in the same scope end up sharing one object, not two independent ones that would silently drift apart.
Closing Upvalues at the Right Moment
Two different events can destroy a stack slot an open upvalue is still pointing at: the block it was declared in ending (Chapter 4's own end_scope), or the whole function returning (Chapter 6's own OP_RETURN). Both now close any upvalues affected, before the slot actually disappears.
close_upvalues simply finds no matching entry and does nothing beyond the ordinary pop. The cost is a wasted dictionary lookup on locals nothing ever closed over; the benefit is not needing separate compile-time bookkeeping for "capturable" versus "ordinary" locals.
Verified: a Closure Outlives Its Own Creator
counter() called three times in a row: ['1', '2', '3']. Directly inspecting the closure's own captured upvalue immediately after var counter = makeCounter(); completes — before counter() is ever called — confirms .closed == True and .value == 0.0: count's value was copied out and preserved at the exact moment makeCounter returned, exactly as designed.
counterA(), counterA(), counterB(): ['1', '2', '1']. Each call to makeCounter declares its own count local, at its own stack position, captured by its own distinct Upvalue object — nothing is shared between the two closures just because they came from the same function declaration.
Verified: Two Closures Sharing the Same Live Variable
fun makePair() { var shared = 0; fun setter(v) { shared = v; } fun getter() { return shared; } setter(42); return getter(); } returns 42. setter and getter are two different closures, but capture_upvalue's own dedup-by-slot check means they share the identical Upvalue object — a write through one is visible through the other, because there was never a copy to begin with while makePair is still on the frame stack.
The Classic Bug: Closing Over a Loop's Own Counter
Course 1, Chapter 6 desugars for (var i = 0; i < 3; i = i + 1) { ... } into a var i declared once, outside a while loop — not redeclared fresh each iteration. If a closure inside the loop body captures i, every iteration's closure captures the same upvalue, because there's only ever one i.
i and stored for later, then all called after the loop finishes: ['3', '3', '3'] — not 0, 1, 2 as each iteration's own "current" value might suggest. This is the exact same well-known behavior real JavaScript's own var-based for loops have, and for the identical structural reason: one binding, shared, not reset per iteration.
This isn't a bug this chapter's own upvalue mechanism introduced — it's an honest, correct consequence of how for was desugared back in Course 1, faithfully reproduced. Wisp programmers hit the same trap real JavaScript programmers did before let existed.
The Fix: a Genuinely Fresh Local, Every Iteration
var captured = i; — before each closure is created, then having the closures capture captured instead of i directly: ['0', '1', '2']. captured is declared fresh inside the body block on every single iteration; Chapter 4's own end_scope (now emitting OP_CLOSE_UPVALUE) closes the previous iteration's own captured before the next one is even declared, so each iteration's closure gets a genuinely distinct Upvalue, already closed with its own value, by the time the loop moves on.
Verified: Capture Two Function Levels Out
outer declares x = 100; middle, declared inside outer, declares inner, which returns x directly — two levels out, skipping middle entirely. Result: 100. middle's own compiler never uses x itself, but resolve_upvalue's own recursive walk still registers an upvalue on middle (with is_local=False, forwarding outer's own upvalue index) purely so inner has something to capture — middle silently relays a variable it never touches.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
An Upvalue switches from pointing at a stack slot to holding its own copy | Chapter 6's own OP_RETURN, which deletes a function's locals immediately — the exact event this chapter's close_upvalues now races to get ahead of |
Two closures capturing the same local share one Upvalue object, by dedup | Course 1, Chapter 7's own WispFunction(declaration, closure) — one shared Environment object achieved the same sharing for free in a tree-walking interpreter; here it takes a deliberate dedup check instead |
| Closures over a shared loop counter all see the same final value | Course 1, Chapter 6's own for-desugaring decision (one var i, not redeclared per iteration) — this chapter didn't create that behavior, it faithfully surfaced it |
OP_CLOSE_UPVALUE replaces every scope-exit OP_POP for a local | Chapter 4's own end_scope — a direct revision, the same way Chapter 6 revised local addressing to be frame-relative |
Hands-On Exercises
Compile and run just fun makeCounter() {...} var counter = makeCounter(); (no calls to counter yet) using this chapter's own Compiler/VM. Inspect counter's own .upvalues[0] object directly and report its .closed and .value fields. Explain exactly which line of code caused .closed to become True, and why that happens before counter is ever actually called.
Compile and run fun makePair() { var shared = 0; fun setter(v) { shared = v; } fun getter() { return shared; } setter(42); return getter(); }. Confirm setter's own .upvalues[0] and getter's own .upvalues[0] are the exact same Python object (using is, not ==), and explain which specific line in capture_upvalue is responsible for that being true rather than each closure getting its own separate copy.
Reproduce this chapter's own loop-counter-capture bug (three closures over a shared for-loop counter, all returning the loop's final value), then reproduce this chapter's own fix (a fresh var captured = i; declared inside the loop body before each closure is created). Verify both results, and explain specifically why the fix's own captured variable gets a fresh Upvalue every iteration while the original i never does.
Chapter 7 Quick Reference
Upvalue: open (reads/writes go to a live stack slot) or closed (reads/writes go to its own private copy) — the closure using it never has to know which- Compile time:
resolve_upvaluewalks outward through enclosing compilers;is_localdistinguishes "capture a local directly" from "forward an upvalue an enclosing function already has" OP_CLOSURE: builds the real runtimeClosure, resolving each descriptor viacapture_upvalue(dedup'd by slot) or by forwardingframe.closure.upvalues[index]- Verified: a counter closure keeps working three calls after its own creator returned; two separate calls to the same outer function produce genuinely independent counters
- Verified: two different closures capturing the same still-open local share the identical
Upvalueobject, not just an equal value - Verified — the classic bug: three closures over a shared
for-loop counter all report the loop's final value (['3','3','3']), reproducing real JavaScript's own well-knownvar-in-a-loop gotcha for the identical structural reason - Verified — the fix: a fresh local declared inside the loop body, captured instead of the loop counter itself, gives each closure its own value (
['0','1','2']) - Next chapter: Garbage Collection — a real mark-and-sweep collector, needed now that closures can keep objects alive indefinitely