57. Closures and upvalues
In the VM, locals live in stack slots that are reused the moment a call returns. A closure needs one of them to stay alive. The fix is a small object that points into the stack, and moves off it at the last moment.
- Explain why a closure in a stack VM can't just remember a slot number.
- Read the (isLocal, index) pairs after OP_CLOSURE and say where each upvalue comes from.
- Trace an upvalue from open (pointing at a stack slot) to closed (holding the value itself).
- Say when the VM closes upvalues, and why the open list is kept sorted.
The counter, again
You met this program in chapters 6 and 41.
fn makeCounter() {
let n = 0;
fn count() {
n = n + 1;
return n;
}
return count;
}
let a = makeCounter();
print a(); // 1
print a(); // 2
The tree-walker got this almost for free: every call made an
Environment on the heap, and count kept a reference to it.
In the VM, n is a stack slot at a fixed offset from the frame pointer
(chapter 54). When makeCounter returns, OP_RETURN sets the stack top
back to the frame’s base, and the next call writes over the slot.
So count can’t just remember “slot 2”. By the time a() runs, slot 2
belongs to someone else.
Upvalues: found at compile time
A coat-check ticket. During the party it leads to a hook on the rack. When the cloakroom closes, your coat moves to a locker, and the attendant writes the locker number on the same ticket. You hold one ticket throughout; only where it leads changes.
Each closure gets a list of small objects, one per variable it uses from an enclosing function: its upvalues. The body says “my upvalue 0”, and the upvalue knows where the value is.
The compiler already tracks every function’s locals (chapter 54), so it
can find these variables as it goes. When a name isn’t a local of the
current function, namedVariable tries resolveUpvalue before falling
back to a global:
function resolveUpvalue(c, name) {
if (!c.enclosing) return -1;
const local =
resolveLocal(c.enclosing, name);
if (local !== -1) {
c.enclosing.locals[local]
.isCaptured = true;
return addUpvalue(c, local, true);
}
const up =
resolveUpvalue(c.enclosing, name);
if (up !== -1) {
return addUpvalue(c, up, false);
}
return -1;
}
Two cases. If the name is a local of the function directly around this
one, the upvalue captures that local’s slot (isLocal true). Otherwise
the function recurses outward. If some outer function has it, the
enclosing function gets an upvalue of its own, and this function’s
upvalue copies that one (isLocal false). addUpvalue appends
{ index, isLocal } to the function’s list, unless an equal entry is
already there, and returns its position.
When the function is done, the compiler writes that list into the
bytecode as byte pairs after OP_CLOSURE. The companion’s disassembler
(chapter 46) shows them:
== makeCounter ==
0000 2 OP_CONSTANT 0 '0'
0002 6 OP_CLOSURE 1 <fn count>
0004 | local 1
== count ==
0000 4 OP_GET_UPVALUE 0
0002 | OP_CONSTANT 0 '1'
0004 | OP_ADD
0005 | OP_SET_UPVALUE 0
local 1 means: capture slot 1 of the running frame, which is n
(slot 0 holds the function itself). Inside count, n became
OP_GET_UPVALUE 0 and OP_SET_UPVALUE 0. With three levels, say
inner inside middle inside outer, where only inner uses outer’s
x, the middle function passes it along: middle gets local 1, and
inner gets upvalue 0, meaning “take my enclosing closure’s upvalue 0”.
An upvalue is a closure’s handle on a variable declared in an
enclosing function. The compiler resolves upvalues at compile time and
emits one (isLocal, index) pair per upvalue after OP_CLOSURE:
capture slot index of the running frame, or reuse upvalue index of
the running closure. The body reaches them with OP_GET_UPVALUE and
OP_SET_UPVALUE.
Open: pointing into the stack
At run time, OP_CLOSURE builds the closure and fills in its upvalues,
one pair at a time:
case OP.CLOSURE: {
const cl = newClosure(readConstant());
push(cl);
const ups = cl.upvalues;
for (let i = 0; i < ups.length; i++) {
const isLocal = readByte();
const index = readByte();
ups[i] = isLocal
? captureUpvalue(frame.slots + index)
: frame.closure.upvalues[index];
}
break;
}
frame.slots + index turns a frame-relative slot into an absolute stack
index. The upvalue object (ObjUpvalue) has just three fields: slot,
closed and nextOpen. While makeCounter runs, n really is in
slot 2, so the upvalue records slot: 2, and reading through it reads
the stack. That’s an open upvalue.
There is one subtlety. Two closures made in the same call must share
n, not each get a copy. So the VM keeps every open upvalue on a list,
openUpvalues, and captureUpvalue looks there first:
captureUpvalue(slot: number) {
let prev = null;
let u = this.openUpvalues;
while (u && u.slot > slot) {
prev = u;
u = u.nextOpen;
}
if (u && u.slot === slot) return u;
const made = heap.newUpvalue(slot);
made.nextOpen = u;
if (prev) prev.nextOpen = made;
else this.openUpvalues = made;
return made;
}
The list is sorted by slot, highest first. The search stops once it passes the slot it wants, and links a new upvalue in at that spot.
An open upvalue is an upvalue whose variable is still on the stack:
its slot is the stack index of the variable, and reads and writes go
straight to stack[slot]. The VM keeps all open upvalues on one list
sorted by slot, and reuses an existing one when a second closure
captures the same slot, so the closures share the variable.
Closed: moving to the heap
Now step through what happens when makeCounter returns:
fn makeCounter() {let n = 0;fn count() {n = n + 1;return n;}return count;}let a = makeCounter();print a();
The move happens at the last possible moment. OP_RETURN calls
closeUpvalues(frame.slots) before it drops the frame:
closeUpvalues(last: number) {
while (this.openUpvalues &&
this.openUpvalues.slot >= last) {
const u = this.openUpvalues;
u.closed = this.stack[u.slot];
u.slot = -1;
this.openUpvalues = u.nextOpen;
}
}
Every open upvalue at or above the frame’s base points into the dying
frame. The loop copies each value into the upvalue’s own closed
field, marks it closed, and unlinks it. Because the list is sorted, the
first upvalue below last ends the loop: everything after it is lower
still. From then on, readUpvalue sees slot === -1 and returns
closed instead of stack[slot].
A function’s return isn’t the only time a local dies. A local declared
in a block dies at the block’s }. That’s why resolveUpvalue set
isCaptured: when endScope discards a local, it emits OP_POP for an
ordinary one and OP_CLOSE_UPVALUE for a captured one, which closes it
(closeUpvalues(sp - 1)) and then pops it. So a closure made in a loop
body that captures a body local gets a fresh upvalue on every pass. The
extra exercise at the end puts that to work.
A closed upvalue holds its variable’s value in its own closed
field, on the heap, and no longer refers to the stack (slot is
-1). The VM closes upvalues when the variable’s slot is about to be
discarded: at OP_RETURN for every slot in the returning frame, and at
OP_CLOSE_UPVALUE for a captured local leaving a block. Closures that
shared the open upvalue share the closed one too.
Lua’s implementation uses the same scheme. Its luaF_findupval walks a
list of open upvalues kept in stack order and reuses a match, and
luaF_closeupval moves each value into the upvalue and repoints the
upvalue’s value pointer at that copy[2]. In C, a
pointer can aim at a stack slot or at the upvalue’s own field, so a read
never has to ask which. TypeScript can’t point into the middle of an
array, so the companion stores an index and uses -1 for “closed”.
Drip’s VM follows clox from Crafting Interpreters[1].
Compare the cost with chapter 41. The tree-walker allocated an environment for every call. The VM allocates nothing extra for a call, and one small upvalue only for a variable a closure really captures.
Your turn
A tiny model of the VM’s upvalues. vm.stack is an array of values and vm.open is an array of open upvalues, each { slot, closed } (unsorted here, to keep it short). Write capture(vm, slot): return the open upvalue for slot if there is one, otherwise make { slot, closed: null }, add it to vm.open and return it. Write closeFrom(vm, last): close every open upvalue whose slot is >= last (copy the value, set slot to -1) and remove it from vm.open. read is given.
function capture(vm, slot) {
// reuse an open upvalue for slot,
// or make and remember a new one
}
function closeFrom(vm, last) {
// close upvalues with slot >= last
}
function read(vm, u) {
return u.slot === -1
? u.closed
: vm.stack[u.slot];
}On the VM, this loop saves three closures, and every one prints 4. Fix it without adding a function: change only the loop body so that f1(), f2() and f3() print 1, 2 and 3.
let f1;
let f2;
let f3;
for (let i = 1; i <= 3; i = i + 1) {
fn show() { print i; }
if (i == 1) f1 = show;
if (i == 2) f2 = show;
if (i == 3) f3 = show;
}
f1();
f2();
f3();What you learned
- In a stack VM, a local’s slot is reused once its call returns, so a closure needs an upvalue: a small object standing between it and the variable.
- The compiler resolves upvalues at compile time and emits
(isLocal, index)pairs afterOP_CLOSURE: capture a local of the enclosing frame, or reuse the enclosing closure’s upvalue. - An open upvalue points at a live stack slot. The VM keeps open upvalues on one sorted list and reuses them, so closures share variables.
- A closed upvalue has copied its value into itself. Closing happens
at
OP_RETURNand atOP_CLOSE_UPVALUE, just before the slot dies.
Next: the VM has been allocating closures, upvalues, strings and instances, and freeing none of them. Time for a garbage collector.
Check your understanding
0/6- 1.
In the VM, why doesn’t
nsurvive on its own aftermakeCounterreturns? - 2.
A closure is created with the pairs
local 1andupvalue 0. What does it get? - 3.
Two closures made in the same call capture the same local, and one of them changes it. What does the other see?
- 4.
What does
closeUpvalues(last)do to each open upvalue whose slot is at leastlast? - 5.
Where does the compiler emit
OP_CLOSE_UPVALUE? - 6.
Why is the open-upvalue list sorted by stack slot?
References
- [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
- [2] Roberto Ierusalimschy, Luiz Henrique de Figueiredo, Waldemar Celes (2026). lfunc.c: auxiliary functions to manipulate prototypes and closures (Lua source code). The Lua programming language, official source repository (github.com/lua/lua), Lua.org, PUC-Rio. github.com/lua/lua/blob/master/lfunc.c (checked 2026-10-31)