VM, Value, Chunk and OpCode from episode 136 fresh in mind -- today we teach that machine to remember;Learn Zig Series):Last episode we built a machine that runs. A Chunk of bytecode goes in, the fetch-decode-dispatch loop grinds over it, and 2 + 3 * 4 comes out as 14 on our own little processor written in Zig. But I ended on a confession: the machine has no memory. It can only compute with values that were literally in the source code. There is no way to say let x = 5 and reach for x again three lines later, and there is certainly no way for a function to reach out of itself and grab a variable that belongs to the function that created it. That last trick -- a function reaching back into the scope it was born in and carrying a piece of it away -- is called a closure, and it is one of the most quietly powerful ideas in the whole of programming. Today we build it from the ground up.
I want to be honest about why this episode is marked Advanced. Closures are not hard because the idea is complicated -- the idea is almost embarrassingly simple once it clicks. They are hard because they force a collision between two things you have been keeping comfortably separate: the lifetime of a variable, and the lifetime of the code that uses it. A local variable normally dies when its function returns. A closure is a function that refuses to let go. Reconciling those two facts is the entire subject. Here we go!
Three exercises last time, all growing the VM. Full code for each, as always.
Exercise 1 -- Add comparison and boolean opcodes. The task was to give the machine less, greater and equal, each popping two values and pushing a boolean, with equal working across types and the ordering comparisons refusing anything but two numbers of the same type. The heart of it is two small helpers -- one honest about type mismatches, one that compares tag-then-payload so an int is never accidentally "equal" to a float:
const std = @import("std");
const Value = union(enum) {
int: i64,
float: f64,
boolean: bool,
};
const CmpError = error{TypeMismatch};
fn less(a: Value, b: Value) CmpError!bool {
if (a == .int and b == .int) return a.int < b.int;
if (a == .float and b == .float) return a.float < b.float;
return error.TypeMismatch;
}
fn equal(a: Value, b: Value) bool {
return switch (a) {
.int => |x| b == .int and b.int == x,
.float => |x| b == .float and b.float == x,
.boolean => |x| b == .boolean and b.boolean == x,
};
}
test "3 < 5 is true, cross-type equality is false, mixed order is an error" {
try std.testing.expect(try less(.{ .int = 3 }, .{ .int = 5 }));
try std.testing.expect(!equal(.{ .int = 3 }, .{ .float = 3.0 }));
try std.testing.expectError(error.TypeMismatch, less(.{ .int = 1 }, .{ .boolean = true }));
}
My rule, documented as the exercise asked: an int and a float of the same magnitude are never equal. This matches Python 2's old surprise-free behaviour and sidesteps the whole quagmire of 3 == 3.0000001. In the run loop each of these becomes one arm that pops b, pops a, and pushes .{ .boolean = ... } -- exactly the pop-two-push-one shape from last episode, just producing a boolean instead of a number.
Exercise 2 -- A print opcode with an out-of-band effect. The important discipline here was: do NOT write to stdout. A VM that prints straight to the terminal is a VM you cannot test. Instead the machine owns a std.ArrayList(u8) buffer, and print appends the textual form of the top value to it. Then a test just asserts on the buffer's contents:
const std = @import("std");
const Value = union(enum) {
int: i64,
float: f64,
boolean: bool,
};
fn printValue(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, v: Value) !void {
var tmp: [64]u8 = undefined;
const text = switch (v) {
.int => |n| try std.fmt.bufPrint(&tmp, "{d}", .{n}),
.float => |f| try std.fmt.bufPrint(&tmp, "{d}", .{f}),
.boolean => |b| if (b) "true" else "false",
};
try buf.appendSlice(alloc, text);
}
test "print appends 42 into the VM's own output buffer" {
const a = std.testing.allocator;
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(a);
try printValue(&buf, a, .{ .int = 42 });
try printValue(&buf, a, .{ .boolean = true });
try std.testing.expectEqualStrings("42true", buf.items);
}
Formatting into a fixed [64]u8 stack buffer first, then appending the slice, keeps the whole thing allocation-light -- a single 64-byte integer or float never overflows that. Routing output through a buffer is not a testing gimmick, by the way; it is exactly how every serious language runtime captures program output for its own test suite.
Exercise 3 -- Pre-size the stack from maxStackDepth. We computed a chunk's peak stack depth back in the ep135 solutions. The pay-off is that we can reserve exactly that much capacity once, before the machine runs, and then push with the non-failing appendAssumeCapacity -- so the operand stack never reallocates mid-execution:
const std = @import("std");
test "a pre-sized stack still computes 2 + 3 * 4 = 14 without reallocating" {
const a = std.testing.allocator;
var stack: std.ArrayList(i64) = .empty;
defer stack.deinit(a);
// maxStackDepth for this chunk was 3 -- reserve once, up front.
try stack.ensureTotalCapacity(a, 3);
stack.appendAssumeCapacity(2);
stack.appendAssumeCapacity(3);
stack.appendAssumeCapacity(4);
const rhs = stack.pop().?; // 4
const lhs = stack.pop().?; // 3
stack.appendAssumeCapacity(lhs * rhs); // 12
const b = stack.pop().?; // 12
const c = stack.pop().?; // 2
try std.testing.expectEqual(@as(i64, 14), b + c);
}
This is safe only because the depth was computed from the exact same bytecode the machine runs -- a point worth restating in a comment in real code, because if the two ever drift apart, appendAssumeCapacity writing past capacity is undefined behaviour, not a friendly error. The stack pre-sizing is a genuine production technique, but it lives or dies on that invariant.
Let me define it in one sentence and then earn the sentence: a closure is a function together with the environment of variables it captured when it was created. Two halves -- code, and captured data -- travelling as one value.
Here is the canonical example, in the pseudo-language our little compiler is inching toward:
fn makeAdder(n) {
return fn(x) { return x + n; };
}
let add5 = makeAdder(5);
add5(3); // 8
makeAdder takes n, and returns an inner function that adds n to its argument. When we call makeAdder(5), the inner function captures n = 5 and hands it back. Crucially, makeAdder has already returned by the time we call add5(3). Its stack frame is gone. Its local n should, by every ordinary rule of the stack, be dead and buried -- and yet add5 still knows that n is 5. That surviving-but-invisible variable is called an upvalue (Lua's excellent term): a variable that lives in an enclosing function but is used by an inner one. The whole game of implementing closures is making that upvalue survive.
If you have internalised episode 8, your instinct is probably: "just keep a pointer to n." And that instinct is exactly right, and exactly dangerous. Consider the naive version, which I am showing you so you do not write it:
// BROKEN ON PURPOSE -- do not do this.
const BadAdder = struct {
n: *const i64, // pointer into makeAdder's stack frame
fn call(self: BadAdder, x: i64) i64 {
return self.n.* + x; // reads freed stack memory
}
};
fn makeBadAdder(n: i64) BadAdder {
const local = n;
return .{ .n = &local }; // returning a pointer to a dying local!
}
The moment makeBadAdder returns, local ceases to exist, and the pointer BadAdder carries is dangling. In C this is the classic "returning address of a stack variable" bug, and it is the single most common way beginners try to build closures and get a security advisory instead. Zig will not save you here -- a pointer to a local is a perfectly legal thing to form; it only becomes wrong once the frame unwinds. So the real question is not "how do I point at n", it is "how do I make n outlive the frame it was declared in". And once you phrase it that way, the answer is obvious: n cannot live on the stack. It has to move to the heap.
We put the captured variable in a small heap cell -- a box -- and the closure holds a pointer to the box, not to the stack. The box outlives the function that created it, so the pointer stays valid for exactly as long as any closure still refers to it. Let me build the humble counter, the "hello world" of closures, because it shows off the second magic property too: a captured variable is not just readable, it is persistent mutable state that lives between calls.
const std = @import("std");
const Cell = struct { value: i64 };
const Counter = struct {
count: *Cell, // the upvalue: a shared reference to a captured variable
fn next(self: Counter) i64 {
self.count.value += 1;
return self.count.value;
}
};
fn makeCounter(alloc: std.mem.Allocator) !Counter {
const cell = try alloc.create(Cell);
cell.* = .{ .value = 0 };
return .{ .count = cell };
}
test "each counter keeps its own private, persistent state" {
const a = std.testing.allocator;
var c1 = try makeCounter(a);
defer a.destroy(c1.count);
var c2 = try makeCounter(a);
defer a.destroy(c2.count);
try std.testing.expectEqual(@as(i64, 1), c1.next());
try std.testing.expectEqual(@as(i64, 2), c1.next());
try std.testing.expectEqual(@as(i64, 3), c1.next());
try std.testing.expectEqual(@as(i64, 1), c2.next()); // fully independent
}
Look at what fell out. Each call to makeCounter mints a fresh Cell on the heap, so c1 and c2 have entirely separate hidden state -- c1 counting up to 3 does not budge c2. And the state persists: next() reaches into the box, increments the stored integer, and the new value is still there next call. This is the exact behaviour a garbage-collected language gives you for free with let count = 0; return () => ++count, except here nothing is free and everything is visible. We allocated the box; we own the box; we destroy the box. Zig makes the closure's memory cost impossible to overlook, which for a systems language is a feature, not a chore.
Here is where the pointer-to-a-box model really pays off, and where closures stop being a curiosity and start being a design tool. If two closures capture the same variable, they should see each other's writes -- because they are not each holding a copy, they are both holding a reference to the one shared cell. A getter and a setter over a shared private variable is the closure version of encapsulation:
const std = @import("std");
const Cell = struct { value: i64 };
const Getter = struct {
cell: *Cell,
fn get(self: Getter) i64 {
return self.cell.value;
}
};
const Setter = struct {
cell: *Cell,
fn set(self: Setter, v: i64) void {
self.cell.value = v;
}
};
test "two closures over one upvalue share mutable state" {
const a = std.testing.allocator;
const cell = try a.create(Cell);
defer a.destroy(cell);
cell.* = .{ .value = 0 };
const g = Getter{ .cell = cell };
const s = Setter{ .cell = cell };
s.set(99);
try std.testing.expectEqual(@as(i64, 99), g.get()); // setter's write is visible to getter
}
g and s are two different closures, two different functions, but they close over the same box. Write through one, read through the other. If you have ever wondered how a JavaScript module pattern hides a variable behind a pair of accessor functions, this is the machinery underneath, laid bare. The shared cell is the private field; the closures are the methods.
So far each "closure" has been a bespoke struct. Real languages need a general representation: a function that could capture any number of upvalues, chosen at runtime. The standard shape is a struct pairing a function pointer with an environment -- a slice of upvalue cells the function can index into:
const std = @import("std");
const Cell = struct { value: i64 };
const Closure = struct {
func: *const fn (env: []const *Cell, arg: i64) i64,
env: []const *Cell, // captured upvalues, in a fixed order
fn call(self: Closure, arg: i64) i64 {
return self.func(self.env, arg);
}
};
fn addUpvalue(env: []const *Cell, arg: i64) i64 {
return env[0].value + arg; // "n + x", where n is upvalue #0
}
test "a closure carries its captured environment alongside its code" {
const a = std.testing.allocator;
const cell = try a.create(Cell);
defer a.destroy(cell);
cell.* = .{ .value = 5 };
const env = try a.alloc(*Cell, 1);
defer a.free(env);
env[0] = cell;
const add5 = Closure{ .func = addUpvalue, .env = env };
try std.testing.expectEqual(@as(i64, 8), add5.call(3)); // 5 + 3
}
Now add5 is precisely the makeAdder(5) from the top of the episode, rendered concrete. The func field is the code, the env field is the captured data, and calling the closure just threads the environment into the function. This is not a toy simplification -- it is the actual layout, minus the memory management, of a closure object in a real bytecode runtime. Lua calls it a Closure; CPython bundles a func_closure tuple onto its function objects; the shapes rhyme because there is really only one good shape.
Now for the clever part, the thing that separates a teaching closure from a production one. Boxing every captured variable on the heap works, but it is wasteful. Most captured variables are grabbed by an inner function while the outer function is still running -- the variable is right there, alive, on the stack. Heap-allocating it immediately, before we even know the closure will outlive the frame, is paying for a problem we might not have.
So the real VMs (Lua's design, faithfully copied by Bob Nystrom's Crafting Interpreters and by many others) split an upvalue into two states. While the variable it captures is still live on the stack, the upvalue is open: it holds a plain pointer into the stack slot, and reading through it is as cheap as reading the stack. Only when that stack slot is about to disappear -- when the enclosing function returns -- does the runtime close the upvalue: it copies the value out of the dying slot into the upvalue itself, and re-points the upvalue at its own internal storage. Same interface, two backing stores. Here is that state machine in miniature:
const std = @import("std");
const Value = union(enum) {
int: i64,
float: f64,
boolean: bool,
};
const Upvalue = struct {
location: *Value, // points at a live stack slot while OPEN
closed: Value = undefined, // holds the value once CLOSED
is_closed: bool = false,
fn get(self: *Upvalue) Value {
return if (self.is_closed) self.closed else self.location.*;
}
fn close(self: *Upvalue) void {
self.closed = self.location.*; // copy the value out of the stack
self.location = &self.closed; // re-point at our own storage
self.is_closed = true;
}
};
test "closing an upvalue detaches it from the stack slot for good" {
var slot: Value = .{ .int = 7 }; // pretend this is a VM stack slot
var up = Upvalue{ .location = &slot };
try std.testing.expectEqual(@as(i64, 7), up.get().int); // open: reads the stack
up.close();
slot = .{ .int = 999 }; // the stack slot is reused for something else
try std.testing.expectEqual(@as(i64, 7), up.get().int); // closed: still sees 7
}
Read that test slowly, because it is the whole idea in eight lines. While the upvalue is open, get() chases the pointer straight into the stack slot -- zero copies, and if the outer function writes to that local, the closure sees the change instantly (that is the shared-mutable-state property from earlier, for free). Then we close() it, and afterwards we deliberately clobber the stack slot with 999 to simulate the frame going away and its memory being reused. The closed upvalue does not care: it copied 7 into its own closed field and now reads from there. The closure survives the death of the frame, and it only paid for the heap-adjacent storage at the exact moment it became necessary. That is the difference between "closures work" and "closures are cheap".
To make this real inside the machine from episode 136, the compiler and the runtime both grow. On the compiler side, when it resolves a variable name it now asks three questions in order: is this a local in the current function? If not, is it a variable in an enclosing function -- an upvalue? If not, it must be a global. That resolution decides which opcode to emit. On the runtime side, the instruction set gains a handful of members:
const std = @import("std");
const OpCode = enum(u8) {
constant,
add,
sub,
mul,
div,
get_local, // push a local variable's value, by stack slot
set_local, // store the top of stack into a local slot
closure, // build a closure object, capturing its upvalues
get_upvalue, // read a captured variable through the closure
set_upvalue, // write a captured variable through the closure
ret,
};
test "the closure-era opcodes extend last episode's instruction set" {
// get_local/set_local give the machine memory; the upvalue ops give it capture.
try std.testing.expect(@intFromEnum(OpCode.get_upvalue) > @intFromEnum(OpCode.get_local));
try std.testing.expectEqual(@as(u8, 0), @intFromEnum(OpCode.constant)); // ep136 opcodes keep their slots
}
The get_local/set_local pair is the "memory" I promised at the end of last episode -- a local variable is nothing more than a fixed slot in the current call frame's region of the stack, addressed by index. The closure opcode is the interesting one: when the machine hits it, it reads the function to wrap plus a little table describing each upvalue (is it captured from a local of the enclosing frame, or is it an upvalue the enclosing closure itself already holds?), and builds the closure object with its Upvalue pointers wired up -- open ones pointing at live stack slots, to be closed later when those slots retire. Nota bene: I am keeping the full call-frame plumbing for when we tackle function calls properly; today the goal is that the capture mechanism underneath is no longer a mystery.
Two Zig properties did quiet heavy lifting all through this episode. First, tagged unions: our Value carries its own type tag, so when a closed upvalue stores a Value, it stores what kind of value it is too. In C, the classic closure environment is a void* and a size, and reading it back at the wrong type is silent corruption -- the exact footgun a union(enum) makes impossible. Second, explicit allocation: every box we created came from an allocator we passed in, and every create had a matching destroy. There is no hidden closure-allocation happening behind your back the way there is in JavaScript or Python, where every arrow function might heap-allocate and you cannot easily tell. For most application code that invisibility is a blessing; for a systems language, where a closure in a hot loop allocating on every iteration is a real performance bug, Zig's insistence that you hold the allocator is exactly the honesty you want. The cost is always in the code, in front of you, never in the runtime behind you.
Notice that every test in this episode followed the same recipe, and it is the same recipe as testing the VM itself: construct some state, poke it through the public functions, assert on what comes out. Closures do not need special testing machinery -- they are just data plus code, and both are inspectable. The three properties worth pinning down with tests, every time, are: independence (two closures minted separately do not share state -- the two-counter test), sharing (two closures over the same cell do -- the getter/setter test), and survival (a closure still reads the right value after its birth frame is gone -- the close-then-clobber test). If those three hold, your closure implementation is correct in the ways that actually bite people. The survival test is the one beginners forget, and it is the one that turns into a heisenbug in production, because a dangling upvalue often seems to work until the freed stack memory happens to get reused. Test it on purpose, with a deliberate clobber, and you never ship that bug.
Is all this fast? The open/closed split is precisely the optimisation that makes it fast enough. Reading an open upvalue is a pointer dereference, same cost as reading a local. The heap only enters the picture at closing time, and only for variables that are genuinely captured and genuinely outlive their frame -- which, in real programs, is a small minority of all locals. This is a lovely example of the profiling wisdom from episode 34: do not pay for the general case (heap-box everything) when the common case (captured-while-still-live) admits a cheap path. The languages that went the other way and box every captured variable eagerly -- some naive interpreters do -- measurably suffer for it in closure-heavy code. Having said that, resist the urge to hand-optimise capture before you have measured it mattering; for a great many programs even the eager-boxing version is entirely satiesfied by modern allocators.
Every language that has closures had to solve the survival problem, and comparing their bargains is genuinely illuminating.
C does not have closures, and its usual stand-in is a struct-plus-function-pointer that you fill in by hand -- a void *context you pass alongside every callback, which is literally our Closure { func, env } with the type-safety removed and the bookkeeping made your problem. Every C programmer has written this pattern a hundred times without calling it a closure. It works, and it is a fountain of use-after-free bugs precisely because C will not stop you from putting a stack pointer in that context and returning it -- the very bug we avoided by boxing.
Rust takes closures utterly seriously and encodes the capture mode into the type system: Fn closures capture by shared reference, FnMut by mutable reference, FnOnce by move, and a move || closure takes ownership of what it captures so it can safely outlive the current frame. Rust's borrow checker simply refuses to compile our makeBadAdder -- the dangling-pointer closure is a compile error, not a runtime landmine. That is a different and stricter bargain than Zig's: Rust buys guaranteed memory safety at the cost of a borrow checker you must satisfy; Zig hands you the allocator and trusts you to match your create with a destroy.
Go gives you the JavaScript-style experience -- write a nested func that references an outer variable and it Just Works -- and pays for it with a garbage collector plus escape analysis. The Go compiler decides, at build time, whether a captured variable escapes to the heap (our "close" moment, automated) or can stay on the stack, and the GC cleans up whatever escaped. It is the most ergonomic of the three and the least explicit about cost, which is the whole Go philosophy in one feature. Different trade-offs, one underlying idea -- and you now understand that idea from the box upward, which means none of these three will ever be magic to you again.
Take stock. We can now capture a variable, share it between closures, mutate it through the shared box, and -- the crucial trick -- keep it alive past the death of the frame that declared it, cheaply, by starting open on the stack and closing to the heap only when we must. That is closures, genuinely and completely, minus the compiler resolution and call-frame plumbing that turns it into syntax a user could type. Our little language now has the two things episode 136's machine was missing: a way to remember (locals) and a way to carry memory across function boundaries (upvalues).
The next brick is the one that makes all of this pay off. A closure is only interesting if the language has real function calls -- a call stack of frames, arguments pushed and popped, return addresses, functions as first-class values you can pass around and invoke. The capture machinery we built today is the hard half of that; wiring it to a proper call frame is the half that makes makeAdder(5)(3) something you can actually write. Our machine can hold on to a value now. Soon we teach it to call. ;-)
A capture-by-value closure. Our Cell-based closures all capture by reference -- the shared-mutable-state behaviour. Build a variant that captures by value: a Snapshot closure that copies the captured integer into its own struct at creation time, so later mutations to the original are invisible to it. Write a test that creates the snapshot, then changes the original variable, and asserts the snapshot still returns the old value. Think about which of the two behaviours the counter needs, and which a makeAdder needs.
A two-upvalue closure. Extend the general Closure { func, env } model to a function that captures two upvalues -- say a makeLinear(a, b) returning a closure that computes a * x + b. Store both cells in the env slice (env[0] and env[1]), write the backing function, and test that makeLinear(2, 1) applied to x = 10 returns 21. This proves the environment-as-a-slice design scales past a single capture.
A close that closes many. In a real VM, when a function returns you must close every open upvalue that pointed at that frame's slots, not just one. Model a tiny stack as a [4]Value array and a list of *Upvalues pointing into it, write a closeAll(upvalues, from_index) that closes every upvalue whose location sits at or above from_index, and test that after closeAll, clobbering those stack slots leaves the upvalues' values intact. This is the exact operation the ret instruction will trigger once we have call frames.
Bedankt voor het lezen, en tot de volgende keer -- the machine can remember now, and next time we teach it to call! ;-)