Writing a Compiler/Interpreter: Advanced
Rebuilding Wisp's Runtime — A Bytecode Compiler & Virtual Machine, From Scratch
Table of Contents
- Why Bytecode? From Tree-Walking to a Virtual Machine
- Chunks, Opcodes & a Stack-Based VM
- Compiling Expressions to Bytecode
- Global and Local Variables in Bytecode
- Control Flow & Jumps in Bytecode
- Functions & Call Frames
- Closures & Upvalues in a Bytecode VM
- Garbage Collection: Mark-and-Sweep
- Classes, Instances & Method Dispatch in Bytecode
- Capstone — Benchmarking Wisp: Tree-Walking vs. Bytecode VM
Why Bytecode? From Tree-Walking to a Virtual Machine
Writing a Compiler/Interpreter: Advanced
Chapter 1 · Why Bytecode? From Tree-Walking to a Virtual Machine
Course 1 built a complete, working implementation of Wisp — the same toy language this course continues — as a tree-walking interpreter: source text becomes tokens, tokens become an abstract syntax tree, and running a program means recursively calling accept()/visit_*() on that tree, over and over, every single time a piece of code executes. It works. It's also not how any serious language implementation actually runs code — Python itself, Lua, the JVM, and V8 all compile to some form of bytecode first: a flat, linear sequence of simple instructions, executed by a small virtual machine instead of a recursive tree walk. This course rebuilds Wisp's own runtime that way. This chapter asks the obvious question first, honestly, instead of assuming the answer: is bytecode actually faster, and if so, why?
What Stays, What Changes
Nothing from Course 1's own front end is being thrown away. The lexer (Chapter 1) and the recursive descent parser (Chapters 2-3) already do their job correctly — they turn Wisp source text into an AST, and that part of the pipeline doesn't care what happens to the tree afterward. What changes is everything downstream of the AST.
| Stage | Course 1 (Fundamentals) | Course 2 (this course) |
|---|---|---|
| Lexing & parsing | Unchanged — reused exactly as built in Course 1, Chapters 1-3 | |
| After the AST | The Interpreter walks the tree directly, recursively, every time the program runs | A compiler walks the tree once, producing a flat list of instructions |
| Execution | Python's own call stack, one frame per AST node visited | A small stack-based virtual machine: one flat loop, an explicit data stack, no recursion per instruction |
| Function calls | A new Python Environment per call, tied to Python's own recursive call stack (Course 1, Chapter 7) | Explicit call frames on the VM's own frame stack (Chapter 6) |
| Memory | Python's own garbage collector, invisibly | A real, hand-written mark-and-sweep collector (Chapter 8) |
Measuring the Real Cost of Tree-Walking
Before assuming bytecode is faster, it's worth actually building the smallest honest version of both and timing them. Here's a tree-walking evaluator matching Course 1's own shape — dataclass nodes, double dispatch through accept()/visit_*() — next to a bytecode compiler and a minimal stack-based VM for the same arithmetic:
Both were run on the exact same expression — a 17-node tree computing ((1+2)*3-4)/5 + ((6-1)*2+3) — evaluated 300,000 times, timing the median of 9 runs to cancel out system noise.
The Same Comparison at Real Program Scale
A 17-node arithmetic expression isn't a realistic Wisp program, though — a real script has loops, function bodies, and expressions nested far deeper than one line. Repeating the exact same measurement on a much larger expression (a 201-node chain, then a 601-node one) tells a genuinely different story:
| Expression size | Tree-walk (median) | Bytecode VM (median) | Ratio |
|---|---|---|---|
| 17 nodes | 1.051 µs/eval | 1.374 µs/eval | 0.765× — VM slower |
| 201 nodes | 27.382 µs/eval | 16.318 µs/eval | 1.678× — VM faster |
| 601 nodes | 84.893 µs/eval | 49.620 µs/eval | 1.711× — VM faster |
accept(), then visit_binary(), then two more recursive accept() calls for the children. Python function calls carry real, fixed per-call overhead (a new stack frame, argument binding, a return). The VM pays a similar per-instruction cost inside its loop — but only once per instruction, via plain iteration, never via a nested function call. As the program grows, the tree-walker's cost compounds with both the number of nodes and the cost of recursing through them, while the VM's cost grows only with instruction count. Small programs don't run long enough for that difference to matter; larger ones do.
The Other Cost: Recursion Depth Itself
There's a second, sharper problem with recursive evaluation that has nothing to do with speed: it can simply fail, on a program that's done nothing wrong except be deeply nested. Python's own call stack has a limit — sys.getrecursionlimit(), 1000 by default — and every accept()/visit_*() pair the tree-walker uses to evaluate one level of nesting consumes two of those frames, not one.
Binary nodes, evaluated via accept()/visit_binary(): the deepest chain that evaluates without crashing is 497 levels — depth 498 raises RecursionError. The same chain walked by one single plain recursive function (no accept()/visitor indirection, just a direct call) survives to 996 levels — almost exactly 2× as deep, because each tree level costs one Python stack frame instead of two.
A real Wisp program can absolutely produce nesting this deep — a long chain of string concatenation, a deeply recursive data structure literal, or (as Course 1's own Chapter 7 measured directly) a recursive Wisp function calling itself: exactly 163 levels at Python's default recursion limit, since each nested WispFunction call also rides Python's own call stack. That's a different code path than the expression-nesting case above, but the same underlying cause: recursive evaluation ties Wisp's own limits to the host language's stack, in a way a Wisp programmer has no way to reason about or predict.
run_vm() 500 times in a row, at Python's completely normal, unraised default recursion limit, succeeds every single time. run_vm()'s own for loop never grows the Python call stack no matter how deep the original expression was — depth only affects how many instructions there are, not how deep any single execution needs to recurse.
visit_call recursion. Chapter 6 of this course ("Functions & Call Frames") gives the VM its own explicit call-frame stack — a Python list, not the interpreter's own call stack — so a deeply recursive Wisp function will no longer be bounded by Python's recursion limit at all. That's not a hypothetical benefit; it's the direct, structural fix for a real limitation this exact course already measured.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A naive same-language VM is slower at small scale, faster at larger scale (~1.7×) | Sets realistic expectations for this course's own Chapter 10 capstone, which will benchmark tree-walking against the finished VM on a real, moderately complex program — not a toy expression |
| Double-dispatch tree-walking costs ~2× the usable recursion depth of a single recursive function | Explains, precisely, why Course 1's own Chapter 7 measured a 163-level function-call ceiling rather than something closer to Python's raw 1000-frame limit |
| A flat instruction loop never recurses per node, at any depth | Chapter 6's own call-frame stack, which removes the 163-level ceiling entirely by giving Wisp function calls an explicit frame stack instead of Python's own call stack |
| The lexer and parser are unchanged from Course 1 | Chapter 3's compiler reuses the exact same AST node classes Course 1, Chapter 3 already built — nothing about the front end needs to be rewritten |
The Road Ahead
| Chapter | Builds |
|---|---|
| 2 | Chunks, opcodes, and a minimal fetch-decode-execute VM loop |
| 3 | A single-pass compiler emitting bytecode directly from Wisp source |
| 4 | Global and local variables — locals resolved to stack slots at compile time |
| 5 | Control flow via jump instructions and backpatching |
| 6 | Functions and call frames — the fix for this chapter's own 163-level finding |
| 7 | Closures and upvalues |
| 8 | A real mark-and-sweep garbage collector |
| 9 | Classes, instances, and method dispatch in bytecode |
| 10 | Capstone — benchmarking the finished VM against Course 1's own tree-walking interpreter, on the same program |
Hands-On Exercises
Using this chapter's own TreeWalkEvaluator and run_vm(), build a left-deep expression of depth 300 (601 nodes) and time both approaches evaluating it 20,000 times (median of several runs). Confirm whether the ratio found in this chapter (~1.7× in the VM's favor at 201 nodes) holds, grows, or shrinks at this larger size.
Write a single plain recursive Python function (no accept()/visitor split at all — just one function calling itself directly on node.left and node.right) that evaluates the same left-deep Binary chain this chapter used for its recursion-depth test. Find its own maximum working depth via binary search, and confirm it lands close to double the tree-walker's own 497-level limit.
Rewrite this chapter's own run_vm() to use small integer opcodes (e.g. 0 for PUSH, 1 for ADD) instead of string comparisons, and re-run the 17-node, 300,000-evaluation benchmark from this chapter. Determine whether removing the string comparisons closes the gap with tree-walking, narrows it, or has no real effect — and explain your result in terms of what the benchmark is actually measuring.
Chapter 1 Quick Reference
- Reused unchanged from Course 1: the lexer and the recursive descent parser — only what happens after the AST changes
- Verified: at small scale (17 nodes), a naive same-language bytecode VM is ~24% slower than tree-walking, not faster
- Verified: at larger scale (201-601 nodes), the same VM is consistently ~1.7× faster — the advantage grows with program size, not a fixed constant
- Verified: double-dispatch tree-walking (
accept()/visit_*()) survives to depth 497 beforeRecursionError; a single recursive function survives to depth 996 — ~2×, from one stack frame per level instead of two - Verified: a precompiled flat instruction list runs correctly at any depth, any number of times, at Python's normal recursion limit — the VM's own loop never recurses per node
- Connects directly to: Course 1 Chapter 7's own 163-level recursive-function ceiling, resolved structurally by this course's own Chapter 6
- Next chapter: Chunks, Opcodes & a Stack-Based VM — building the real instruction format and execution loop this chapter only sketched
Chunks, Opcodes & a Stack-Based VM
Writing a Compiler/Interpreter: Advanced
Chapter 2 · Chunks, Opcodes & a Stack-Based VM
Chapter 1's own VM was a sketch — instructions as Python tuples like ('PUSH', 2.0), opcodes as strings, just enough to make an honest measurement. Nothing about that format is how a real bytecode VM actually represents a program. This chapter replaces it with the real thing: a chunk — a flat array of small-integer opcodes with a separate table for constant values — plus a disassembler for actually reading it, and a fetch-decode-execute loop that runs it. By the end, this VM will reproduce Chapter 1's own verified 14.0 result for the exact same expression, on the real representation the rest of this course builds on.
A Real Instruction Format: the Chunk
A Chunk holds three parallel pieces: the instruction bytes themselves, a separate list of the actual values the program uses (its constant pool), and a list recording which source line each byte came from, for error messages later.
code is a Python bytearray, not a list — every element must be a small unsigned integer, 0-255. That's a deliberate constraint, not an oversight: real bytecode is exactly this, a dense run of single bytes, which is what makes it compact and fast to scan compared to a list of arbitrary Python objects.
Opcodes as Small Integers
Chapter 1 used the string 'ADD' as an opcode. A real chunk uses the integer 1 instead — a value that fits in a single byte of code, matching how CPython's own compiled functions, the JVM, and Lua all represent an opcode: one small number, not a string comparison.
Constants Live Separately
An arithmetic literal like 2.0 doesn't fit in one byte — so instead of embedding it directly in the instruction stream (Chapter 1's own approach), OP_CONSTANT takes a one-byte index into the chunk's own constants list. The value itself lives once, in the constant pool; the instruction stream only ever holds small integers.
((1+2)*3-4)/5 + ((6-1)*2+3)) both ways and measuring deep memory size (sys.getsizeof, recursively, over every nested object): Chapter 1's tuple-based encoding takes 1,173 bytes; this chapter's chunk-based encoding — 26 code bytes plus a 9-entry constant pool plus the line array — takes 751 bytes. A 1.56× reduction, from replacing 17 separate Python tuple objects with one dense bytearray and a shared constant pool.
A Real Limitation, Found by Actually Hitting It
A single-byte operand for OP_CONSTANT means the constant-pool index has to fit in one byte too — which caps how many distinct constants a single chunk can hold.
emit_constant in a loop, adding a genuinely new, unique value each time: the 256th constant (index 255) is added successfully. The 257th fails immediately with ValueError: byte must be in range(0, 256) — not a bug in this chapter's own code, but bytearray itself refusing to store an index that no longer fits in one byte.
OP_CONSTANT_LONG using a 3-byte operand for chunks with more than 256 constants). This course doesn't implement that variant — a single Wisp program exercising this course's own chapters is never going to need 257 distinct literal values in one chunk. The ceiling is real, verified, and deliberately left as a known, documented boundary rather than solved for a case this course never actually hits.
Debugging Bytecode: a Disassembler
A bytearray of small integers is unreadable on sight. A disassembler turns it back into something a person can check by eye — essential for verifying the compiler in Chapter 3 is actually emitting what it's supposed to.
Hand-building a chunk for -((1.2 + 3.4) / 2), all on source line 3, and disassembling it:
OP_CONSTANT correctly advances the offset by 2 (opcode plus operand byte) while every other instruction advances by 1 — confirmed by the printed offsets running 0, 2, 4, 5, 7, 8, 9. The | marker (borrowed directly from this course's own established real-tool style) correctly suppresses a repeated line number: building a second chunk spanning two source lines instead of one produced 1 on its first instruction, then 2 on the next (a genuinely new line), then | on the instruction after that (same line as the one before it) — verified against the actual line numbers passed in, not just visually plausible output.
The VM: Fetch, Decode, Execute
The VM itself is one loop: read the byte at the instruction pointer, advance the pointer, act on what was read. No recursion, no call stack growth per instruction — exactly the property Chapter 1 verified matters once expressions get deep.
((1+2)*3-4)/5 + ((6-1)*2+3) as a real Chunk, using emit_constant and the arithmetic opcodes directly, then running it through run_vm: 14.0 — matching both Chapter 1's tree-walking evaluator and its own tuple-based VM, exactly. Three independently-built representations of the same program, three matching answers.
What Happens When Bytecode Is Wrong
Chapter 3 will generate chunks automatically, so hand corruption should never occur in practice — but deliberately breaking a chunk on purpose is the fastest way to find out what this VM actually does with bad input, rather than assuming.
OP_ADD instruction in [0, 0, 0, 1, 1, 6] used chunk.code.index(OP_ADD) — searching for a byte equal to 1. It found offset 3, not offset 4. Offset 3 isn't OP_ADD at all — it's the constant-pool index operand of the second OP_CONSTANT, which happens to equal 1 for an unrelated reason (it's the second constant, index 1). Bytes carry no built-in label saying "I am an opcode" versus "I am data" — the VM only knows which is which by tracking position through the loop. Searching by raw value instead of by position corrupted the wrong thing entirely.
OP_ADD byte (confirmed at its true offset, 4) to an unrecognized value, 42, then running the program: result 3.0 — the second operand alone, quietly returned as if nothing had gone wrong, instead of the correct 5.0. The dispatch chain has no else branch, so an unmatched instr value simply falls through the loop, doing nothing, and the next instruction runs on a stack that's now missing a step.
Adding one guard fixes it:
OP_ADD to 42 now raises RuntimeError: Unknown opcode: 42 at offset 4 instead of silently returning 3.0. Re-running the original, uncorrupted 2 + 3 program afterward still correctly returns 5.0 — the guard only fires on a value none of the real opcodes match.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Chunks are hand-assembled here, one instruction at a time | Chapter 3's compiler emits every chunk in this course automatically from Wisp source — hand-assembly was only ever a way to test the format in isolation |
| The instruction pointer is a plain integer offset into a flat array | Chapter 5's jump instructions (for if/while) work by directly overwriting an operand byte to change where ip lands — only possible because offsets are simple integers, not tree positions |
| The 256-constant ceiling, and the unknown-opcode guard | Both are examples of a theme Course 1's own Chapter 9 established directly: fail loudly and specifically, at the exact point a problem is knowable, rather than letting it surface somewhere unrelated later |
run_vm()'s own flat, non-recursive loop | Chapter 1's own verified finding — this loop's Python call-stack depth never grows with program size, unlike Course 1's tree-walking Interpreter |
Hands-On Exercises
Hand-assemble a chunk for the Wisp expression -(2 + 3) * 4 using this chapter's own emit_constant and opcode helpers, disassemble it to confirm the instruction sequence looks correct, then run it through run_vm and verify the result against a hand-computed expected value.
Modify add_constant to check whether a value is already in self.constants before appending, reusing the existing index if so. Build a chunk that pushes the literal 1.0 three hundred times in a row and confirm the 256-constant ceiling from this chapter no longer applies. Then confirm it still applies when the 300 pushed values are genuinely all different from each other.
This chapter's own unknown-opcode guard catches a corrupted opcode byte. Using the fixed VM (with the else: raise guard added), corrupt the operand byte of an OP_CONSTANT instruction instead (its constant-pool index) to an out-of-range value, and determine whether the guard added in this chapter catches that case too, or whether a different, uncaught error results. Explain precisely why, in terms of which branch of the dispatch chain actually runs.
Chapter 2 Quick Reference
- Chunk: a
bytearrayof small-integer opcodes, a separate constant pool, a parallel line-number array - Verified: the chunk format is 1.56× more compact than Chapter 1's own tuple sketch, for the identical program
- Verified: a single-byte operand caps a chunk at exactly 256 distinct constants — confirmed by actually hitting it
- Disassembler:
disassemble_instruction— verified correct offset advancement (2 bytes forOP_CONSTANT, 1 for everything else) and correct repeated-line suppression - Verified: hand-assembling Chapter 1's own 17-node expression on this chapter's real chunk format reproduces its exact
14.0result - Verified: a naive byte-value search corrupted the wrong thing — opcodes and operands share the same numeric space, distinguished only by dispatch position, not by value
- Verified: an unmatched opcode silently produced a wrong answer until an
else: raiseguard was added — then failed loudly and specifically instead - Next chapter: Compiling Expressions to Bytecode — replacing hand-assembly with a real single-pass compiler over Wisp source
Compiling Expressions to Bytecode
Writing a Compiler/Interpreter: Advanced
Chapter 3 · Compiling Expressions to Bytecode
Every chunk so far has been hand-assembled — a real, correct instruction format (Chapter 2), but built by literally writing emit_constant and chunk.write calls in the right order, by hand, every time. That doesn't scale to a real Wisp program. This chapter builds the actual bridge: a compiler that walks the exact same AST Course 1's lexer and parser already produce, and emits a chunk automatically — replacing hand-assembly for good, everywhere from here on.
Reusing Course 1's Own AST — With One Naming Correction
Chapters 1 and 2 of this course used small, standalone Literal/Binary classes for isolated experiments, with a shorthand op field. Course 1's own real AST — the one the actual lexer and parser build, and the one this compiler has to work against for real Wisp programs — uses a different field name, operator, and includes two more node types this course hasn't touched yet: Grouping (a parenthesized sub-expression) and Unary (the - prefix operator). This chapter switches to the real classes, field name and all.
The Compiler as a Visitor
The shape is deliberately familiar — the same accept()/visit_*() double dispatch Course 1's own Evaluator used. The difference is what each visit_* method does. The Evaluator computed and returned a Python value. The Compiler computes nothing — it emits instructions into a Chunk as a side effect, and returns nothing meaningful at all.
The pattern in every branch is the same: compile the children first, then emit this node's own instruction last. That's not a stylistic choice — it's what makes a stack machine work at all. By the time visit_binary emits OP_ADD, both operands are already sitting on the VM's own stack, pushed there by the two self.visit(...) calls that ran before it. The tree's own shape — which the parser already built correctly, honoring every precedence rule from Course 1, Chapter 2 — becomes the instruction order for free, just by visiting children before parents.
Verified Against Three Independently-Built Trees
"2 + 3 * 4": Binary(left=Literal(2.0), operator='+', right=Binary(left=Literal(3.0), operator='*', right=Literal(4.0))), and verified it evaluates to 14.0. Compiling that same tree object with this chapter's own Compiler, then running the resulting chunk through Chapter 2's run_vm: 14.0 — a second course, a different execution strategy, the exact same already-published tree, the same answer.
((1+2)*3-4)/5 + ((6-1)*2+3)), this time built with real Binary/Literal nodes instead of hand-assembled bytecode, compiled through Compiler, and run: 14.0 — matching every prior verification of this same expression, on its fourth independently-built representation now (tree-walk, hand-assembled bytecode, and now compiler-generated bytecode from a real tree).
-(2 + 3) * 4, built as Binary(Unary('-', Binary(Literal(2.0), '+', Literal(3.0))), '*', Literal(4.0)) and compiled: -20.0 — exactly what Chapter 2's Exercise 1 got from hand-assembling the same expression directly.
Grouping Disappears at Compile Time
Grouping exists in the AST because (2 + 3) needs to be a distinct node from a bare 2 + 3 during parsing — without it, the parser has no way to represent "this sub-expression was explicitly parenthesized." But by the time the tree reaches the compiler, that information has already done its job.
Grouping(Binary(Literal(2.0), '+', Literal(3.0))) and the bare Binary(Literal(2.0), '+', Literal(3.0)) — same expression, one explicitly parenthesized, one not — both compile to exactly 6 code bytes, and those 6 bytes are byte-for-byte identical. visit_grouping emits nothing at all; it just visits the inner expression and returns.
Grouping's entire job was already finished once the parser used it to force the correct nesting shape — wrapping 2 + 3 so that a surrounding * 4 multiplies the sum, not just the 3. Once that shape is baked into the tree, the parentheses themselves carry no further information the compiler needs. This is a small, concrete instance of a bigger idea: different stages of a pipeline need different information, and it's fine — expected, even — for something essential at one stage to vanish completely by the next.
The Compiler Trusts the Tree It's Given
One thing the compiler deliberately does not do: check whether the tree's own shape is correct. It has no way to know that. It just walks whatever Binary/Unary/Literal/Grouping structure it's handed and emits matching instructions.
"2 + 3 * 4" as if the parser had gotten precedence wrong — Binary(Binary(Literal(2.0), '+', Literal(3.0)), '*', Literal(4.0)), grouping the + first instead of the * — compiles with zero errors, zero warnings, into perfectly well-formed bytecode. Running it: 20.0, not 14.0. Nothing in this chapter's own machinery detected anything wrong, because nothing was wrong from the compiler's own point of view — it faithfully translated the tree it was given.
* binds tighter than + gets encoded, once, into how the tree gets built. The compiler's only job is a faithful, mechanical translation of whatever tree it receives. Keeping those two concerns separate means a bug in one is easy to isolate: if a compiled program gives the wrong answer, the very first question is "is the tree shaped correctly?" — checkable independently of anything in this chapter — before ever suspecting the compiler itself.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| The compiler reuses Course 1's own lexer and parser, unchanged | The full pipeline is now: Course 1's lexer & parser (unchanged) → this chapter's Compiler → Chapter 2's Chunk → Chapter 2's run_vm — hand-assembly is retired for good |
| "Compile children before emitting this node's own instruction" | Chapter 5's own jump instructions will break this simple rule on purpose for if/while — a first hint that control flow needs a genuinely different compilation strategy than pure expressions |
| The compiler trusts the tree; correctness is the parser's job | Chapter 2's own unknown-opcode guard — that guard catches a structurally invalid chunk; this chapter's finding shows a structurally valid chunk can still be semantically wrong, a different failure mode entirely |
Constants are added via chunk.add_constant, one per Literal node | Chapter 2's own 256-constant ceiling now applies to real Wisp programs automatically — any expression with more than 256 distinct literals will hit it, not just a hand-built stress test |
Hands-On Exercises
Build a tree for the Wisp expression -(-(5)) — a Unary node wrapping a Grouping-wrapped Unary node — using this chapter's own Compiler and Chapter 2's run_vm. Verify the result, and explain in terms of the compiler's own visit_unary method why two negations correctly cancel out rather than, say, doubling the value.
Build a flat chain of 256 additions (256 distinct Literal values, chained left-to-right through nested Binary nodes), compile it with this chapter's own Compiler, and confirm it compiles successfully. Then build the same shape with 300 distinct literals instead, and determine whether compiling it through the real Compiler — not a hand-built stress test like Chapter 2's own — hits the exact same 256-constant ceiling, and with the exact same error.
Deliberately introduce a bug into BINARY_OPS by mapping '+' to OP_SUBTRACT instead of OP_ADD, then compile and run 2 + 3. Determine the result, and explain why Chapter 2's own unknown-opcode guard (the else: raise RuntimeError(...) added to run_vm) does nothing to catch this specific kind of bug, even though the guard is still present and working exactly as it did in Chapter 2.
Chapter 3 Quick Reference
- Field name correction: Course 1's real AST uses
Binary.operator, not the shorthandopChapters 1-2's own toy examples used - The pattern: compile every child first, then emit this node's own instruction last — the tree's shape becomes the instruction order for free
- Verified: Course 1, Chapter 3's own published
"2 + 3 * 4"tree compiles and runs to14.0, matching that chapter's own tree-walking result exactly - Verified: this course's own 17-node expression, rebuilt as a real AST, still compiles to
14.0— its fourth independently-verified representation - Verified: a
Grouping-wrapped expression compiles to byte-identical bytecode as the unwrapped version — parentheses carry no information the compiler still needs - Verified: a deliberately mis-shaped (precedence-broken) tree compiles with zero errors and produces a confidently wrong answer (
20.0instead of14.0) — the compiler trusts the tree; correctness of shape is the parser's job alone - Next chapter: Global and Local Variables in Bytecode — giving compiled programs somewhere to actually store values between instructions
Global and Local Variables in Bytecode
Writing a Compiler/Interpreter: Advanced
Chapter 4 · Global and Local Variables in Bytecode
Every chunk so far has computed one throwaway value and returned it — nothing has been able to remember anything between instructions. Course 1's own Environment (Chapter 5) solved this with a dict per scope, chained to its parent. This chapter solves it twice, on purpose: globals, looked up by name at runtime, much like Course 1's own approach — and locals, which this chapter resolves entirely differently: not by name, not at runtime, but by position, worked out once, at compile time.
Six New Opcodes
| Opcode | Does |
|---|---|
| OP_POP | Discard the top of the stack — needed for expression statements, and for locals leaving scope |
| OP_DEFINE_GLOBAL | Pop a value, store it in the VM's own globals table under a name |
| OP_GET_GLOBAL | Look up a name in globals, push the result |
| OP_SET_GLOBAL | Overwrite an existing entry in globals — the new value stays on the stack |
| OP_GET_LOCAL | Push a copy of whatever's already sitting at a specific stack slot |
| OP_SET_LOCAL | Overwrite a specific stack slot with the current top of stack — which stays there |
A minimal OP_PRINT is also added here, purely so a multi-statement program can actually show something observable — Course 1's own PrintStmt node needs somewhere to compile to.
Globals: Looked Up by Name, at Runtime
A top-level var x = 10; compiles to: compile the initializer, then OP_DEFINE_GLOBAL with an operand pointing at the constant pool, where the variable's own name — the string "x" — lives as a constant, exactly like any other literal.
At runtime, the VM keeps one plain Python dict, self.globals. OP_GET_GLOBAL reads the name out of the constant pool and looks it up; OP_SET_GLOBAL overwrites an existing entry, matching Course 1's own rule that assignment never implicitly declares.
var x = 10; print x; compiles and runs to ['10']. var x = 10; x = 20; print x; runs to ['20'].
print nope; raises RuntimeError: Undefined variable 'nope'. — and so does nope = 1;, with the identical message. The VM's own OP_GET_GLOBAL/OP_SET_GLOBAL handlers both check if name not in self.globals before touching the dict, refusing to let a bare assignment silently create a new global — the same deliberate rule Course 1, Chapter 5 established.
Locals: Resolved by Position, at Compile Time
A local variable gets no runtime lookup at all — not by name, not by any kind of table. The compiler works out, while compiling, exactly which stack slot a local will occupy, and bakes that slot number directly into the instruction as a plain integer operand.
The trick that makes this work at all: a local's value never moves anywhere new. Once its initializer is compiled, the result is already sitting on the VM's own operand stack — the exact same stack arithmetic uses — at exactly the slot it needs to stay at. declare_local doesn't emit a single instruction; it just remembers, at compile time, which position that value now occupies. Locals aren't stored anywhere separate from the rest of the VM's own working stack.
var x = 1; { var x = 2; print x; } print x; runs to ['2', '1']. Inside the block, resolve_local finds the inner x first (searching innermost-outward) and compiles the print to OP_GET_LOCAL. Outside the block, the inner x has already been removed from self.locals by the time the second print x; is compiled, so it resolves to the global x instead, via OP_GET_GLOBAL. Two completely different instructions, decided entirely while compiling — the VM itself never has to ask "which x did the programmer mean."
var x = 1; { var y = 2; y = 5; print y; } print x; runs to ['5', '1'] — y's reassignment only ever touches its own stack slot, and x in the outer scope is never disturbed by anything happening to a same-numbered-or-not slot inside the block.
Leaving a Block: OP_POP, Once Per Local
When a block ends, every local declared inside it has to actually come off the stack — otherwise the stack keeps growing forever and every slot number calculated afterward would be wrong.
Two things happen for every local going out of scope, and both matter: an OP_POP is emitted so the VM's own stack actually shrinks back down at runtime, and the entry is removed from self.locals so any later resolve_local call can no longer find it — which is exactly the mechanism behind the shadowing test above.
A Real Performance Difference, Honestly Measured
A dict lookup by string name and a plain list index by integer are both, in the abstract, "fast" — but they're not the same cost, and it's worth actually measuring rather than assuming.
OP_GET_GLOBAL, dict lookup) — 0.0931s. Local (OP_GET_LOCAL, list index) — 0.0882s. A 1.056× advantage for locals. A more realistic mixed workload — 200,000 repetitions of i = i + 1, one get and one set per iteration — gives 1.045×, the same modest margin. Both ratios held consistently across repeated runs (median of 11 each).
dict and list types — the theoretical gap between "hash lookup" and "array index" gets mostly absorbed by Python's own implementation before it ever reaches this VM's own code. This is the same shape of result Chapter 1 found for tree-walking versus bytecode dispatch generally: architectural advantages that are large in a compiled, low-level implementation don't automatically transfer at full strength once everything is written in the same interpreted host language.
Same Ceiling, Different Reason
OP_GET_LOCAL/OP_SET_LOCAL encode a slot number as a single operand byte, the same way OP_CONSTANT encodes a constant-pool index — which means Chapter 2's own 256 ceiling shows up again here, for an entirely different quantity.
ValueError: byte must be in range(0, 256) — not because there are too many constants this time, but because a single-byte slot operand can't address a 257th simultaneously-live local. Two genuinely different resources (the constant pool, and the local-variable stack region) hit the identical numeric ceiling, for the identical structural reason: a one-byte operand.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Constant-pool deduplication, reused for variable names | Chapter 2, Exercise 2's own already-verified add_constant fix — without it, reading the same global repeatedly would exhaust the 256-constant ceiling after just 256 reads, a real bug this chapter's own benchmark hit while being built |
| Locals live directly on the VM's own value stack, with no separate storage | Chapter 6's own call frames will extend this same idea — a function call's own locals will occupy a fresh region of the same stack, offset from a frame's own base pointer, rather than needing a new data structure |
| Locals resolved entirely at compile time; globals resolved entirely at runtime | Course 1, Chapter 5's own single, uniform Environment chain — this course deliberately splits what Course 1 handled one way into two genuinely different mechanisms, chosen for what each one is actually used for |
| A modest, honestly-measured ~5% speed advantage for locals over globals | Chapter 1's own finding that Python-on-Python bytecode dispatch doesn't automatically inherit the performance characteristics of a real, compiled VM |
Hands-On Exercises
Compile { var a = 1; { var b = 2; print a; print b; } print a; } — two nested blocks, two locals at different depths — using this chapter's own Compiler. Trace through declare_local/resolve_local by hand to determine which stack slot each of a and b occupies, then verify your trace against the actual output.
Build a Chunk/Compiler pair using a version of add_constant without Chapter 2 Exercise 2's own deduplication fix. Compile a program that declares one global and then reads it 300 times in a row. Determine whether this fails, and if so, explain precisely why — given that the program only ever refers to a single variable name.
Compile and run var x = 1; var y = 2; x = y = 10; print x; print y; — a chained assignment, where the right-hand side of x = ... is itself an Assign expression. Verify the result, and explain specifically which line of visit_assign makes this work without any special-case code for chained assignment at all.
Chapter 4 Quick Reference
- Globals: a name-keyed
dicton the VM;OP_DEFINE_GLOBAL/OP_GET_GLOBAL/OP_SET_GLOBAL, resolved by name at runtime - Locals: plain slots on the VM's own operand stack;
OP_GET_LOCAL/OP_SET_LOCAL, resolved by position at compile time —declare_localemits zero instructions - Verified: the classic shadowing test —
var x=1; {var x=2; print x;} print x;— correctly prints2then1, decided entirely at compile time - Verified: undeclared reads and assignments both fail with a clean
RuntimeError, matching Course 1's own rule that assignment never implicitly declares - Verified: locals beat globals by a real but modest ~5% in this pure-Python VM — architectural advantages don't fully transfer once both paths run through the same host interpreter
- Verified: a 256-slot ceiling applies to simultaneously-live locals too — same one-byte-operand cause as Chapter 2's constant-pool ceiling, different resource
- Next chapter: Control Flow & Jumps in Bytecode — compiling
if/whileusing instructions that overwrite their own operand byte after the fact
Control Flow & Jumps in Bytecode
Writing a Compiler/Interpreter: Advanced
Chapter 5 · Control Flow & Jumps in Bytecode
Every chunk compiled so far has run start to finish, one instruction after the next, with no way to skip anything or repeat anything. Real programs branch and loop. This chapter breaks the "compile children, then emit this node's own instruction" rule Chapter 3 established — on purpose — because if and while need something no earlier chapter needed: an instruction whose own operand can't be known until code that comes after it has already been compiled.
First, Something Worth Branching On
Every value compiled through Chapter 4 has been a plain number — always truthy under Wisp's own rule (only nil and false are falsy). A condition that's always true isn't a condition. Three small comparison opcodes fix that, plus is_truthy, reused directly from Course 1, Chapter 4's own rule:
Nothing new needed for boolean or nil literals — Literal(True) and Literal(None) already flow through Chapter 3's own visit_literal for free, since it treats a constant's Python value generically. Only the comparisons that actually produce a boolean needed new opcodes.
The Problem: a Jump Target That Isn't Known Yet
Compiling if (cond) { thenBranch } means emitting something like "if cond is false, skip past thenBranch." That skip instruction has to say how far to jump — but at the point the compiler is ready to emit it, thenBranch hasn't been compiled yet. Its size in bytes literally doesn't exist until after it's compiled. There's no way to know the jump distance in advance.
Backpatching: Emit a Placeholder, Fix It Later
The fix is to emit the jump instruction with a fake, obviously-wrong operand, remember exactly where those bytes live in the chunk, compile whatever comes next, and then — now that its real size is known — go back and overwrite the placeholder with the correct value.
OP_CONSTANT's operand) caps a jump distance at 255 — comfortably enough for one arithmetic expression, nowhere near enough for a real loop body or function. This chapter's jump instructions read two operand bytes as one 16-bit distance from the start, avoiding a ceiling that program size hits far more easily than constant count or local count ever would. (Verified below — this isn't a precaution taken "just in case.")
Compiling if/else
if (1 < 2) { print 100; } else { print 200; } runs to ['100']. Flip the condition to 5 < 2: ['200']. Drop the else entirely with a false condition — if (5 < 2) { print 100; } print 999; — runs to ['999'] only; the then-branch's own OP_CONSTANT/OP_PRINT pair is skipped over completely.
Disassembling the no-else case for if (1 < 2) { print 100; } shows the backpatch landed exactly where it should:
OP_JUMP_IF_FALSE was written at offset 5 with a placeholder, back when nothing after it existed yet. Only once OP_PRINT at offset 10 had actually been compiled — making the chunk exactly 11 bytes at that point — did patch_jump compute the real distance, 3, and overwrite the two placeholder bytes with it.
Compiling while: Jumping Backward
A loop needs the opposite direction too — after the body runs, control has to go back to re-check the condition. OP_LOOP handles that: same 2-byte offset, but the VM subtracts instead of adds.
loop_start needs no backpatching at all — unlike a forward jump's target, it's already known the moment the loop begins, since nothing about where the condition starts depends on anything compiled later.
var i = 0; while (i < 5) { print i; i = i + 1; } runs to ['0', '1', '2', '3', '4'].
Disassembling a smaller loop (var i = 0; while (i < 2) { i = i + 1; }) shows both directions in one program:
OP_JUMP_IF_FALSE at offset 9 jumps forward, past the whole loop body, to offset 23 once i < 2 goes false. OP_LOOP at offset 20 jumps backward to offset 4 — not offset 0, deliberately: offset 4 is where the condition check begins (OP_GET_GLOBAL i), skipping back over the one-time var i = 0; initialization, which only needs to run once.
Nested Control Flow: Backpatches Don't Interfere With Each Other
var i = 1; while (i < 4) { if (i < 2) { print 100; } else { print 200; } i = i + 1; } runs to ['100', '200', '200'] — correct for i = 1, 2, 3. Each call to emit_jump/patch_jump only ever touches the two placeholder bytes its own jump_pos points at; nothing about compiling the inner if's own two jumps disturbs the outer while's own loop_start or its still-unpatched exit jump.
Proving the 2-Byte Offset Is Necessary, Not Just Cautious
while loop body padded with 150 filler statements compiles to 477 total code bytes and still runs correctly. The same program, compiled with a deliberately naive one-byte jump encoding instead, fails immediately with ValueError: byte must be in range(0, 256) — the exact same failure signature Chapters 2-4 found for the constant pool and the local-slot ceiling, this time triggered by something as ordinary as "a loop body with more than a couple dozen statements in it."
For Loops Need Zero New Compiler Code
Course 1, Chapter 6 made a specific, verified choice: for is desugared at parse time into existing node types — a var declaration, a while loop, and a block — rather than getting its own ForStmt class. That decision pays off here for free.
for (var i = 0; i < 3; i = i + 1) { print i; }, built as its own desugared form — { var i = 0; while (i < 3) { { print i; } i = i + 1; } } — compiles and runs to ['0', '1', '2'], using only visit_var_stmt, visit_while_stmt, and visit_block_stmt, all already written. No visit_for_stmt exists anywhere in this compiler, and none was needed.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Backpatching: emit a placeholder, fix it once the real size is known | The first real break from Chapter 3's own "compile children, then emit this node" rule — control flow genuinely needs a different compilation strategy than pure expressions, exactly as Chapter 3's own closing note predicted |
| Jump offsets use 2 bytes; constants and locals use 1 | Chapters 2 and 4's own 256 ceilings — this chapter deliberately avoids the same mistake for a resource (program size) far more likely to exceed it in ordinary code |
A desugared for loop needed zero new compiler code | Course 1, Chapter 6's own "for fully desugared, zero new interpreter methods" finding — the exact same payoff shows up a second time, one course later, in a completely different execution strategy |
OP_LOOP jumps back to the condition check, not the very start of the loop | Chapter 6's own call frames — a function's own initialization work (parameter binding) will need the same "run once, don't re-loop over it" discipline this chapter's loop_start placement already established |
Hands-On Exercises
Compile if (1 < 2) { print 100; } (no else) using this chapter's own Compiler, then disassemble the resulting chunk. Compute, by hand, the exact value patch_jump writes into OP_JUMP_IF_FALSE's two operand bytes, and verify it against the disassembly's own reported jump distance and target offset.
Build the desugared AST for for (var i = 0; i < 3; i = i + 1) { print i; } — a var declaration, a while loop, and a block, per Course 1, Chapter 6's own desugaring rule — and compile it. Confirm which existing visit_* methods handle every part of it, and identify by name every AST node type involved.
Using this chapter's own comparison of a one-byte versus two-byte jump encoding, find the exact filler-statement count where a one-byte encoding starts failing on a padded while loop body, via binary search (matching the technique this course has used before for similar ceilings). Confirm your answer against a direct compile attempt at that exact count and the count just below it.
Chapter 5 Quick Reference
- New opcodes:
OP_EQUAL/OP_GREATER/OP_LESS(comparisons),OP_JUMP/OP_JUMP_IF_FALSE(forward),OP_LOOP(backward) — all jumps use a 2-byte offset - Backpatching:
emit_jumpwrites a placeholder and returns its position;patch_jumpoverwrites it once the real distance is known - Verified: if/else, no-else, and while all compile and run correctly, confirmed against hand-computed disassembly
- Verified: nested control flow (an
ifinside awhile) compiles correctly — independent backpatches never interfere with each other - Verified: a one-byte jump offset fails on an ordinary-sized loop body (crossover at 79/80 filler statements) — the 2-byte design is necessary, not cautious
- Verified: a desugared
forloop compiles and runs correctly with zero new compiler code, mirroring Course 1 Chapter 6's own identical finding - Next chapter: Functions & Call Frames — resolving Chapter 1's own 163-level recursion ceiling with an explicit frame stack
Functions & Call Frames
Writing a Compiler/Interpreter: Advanced
Chapter 6 · Functions & Call Frames
Chapter 1 of this course made a promise it hadn't yet delivered on: Course 1's own tree-walking interpreter measured a real, hard ceiling of 163 levels for recursive Wisp function calls, because every nested call rode Python's own recursive call stack. This chapter is where that promise gets kept — real function declarations, real calls, and an explicit call frame stack that replaces Python's own recursion entirely. By the end, a Wisp function will be able to call itself tens of thousands of levels deep without Python's interpreter ever noticing.
A Wisp Function Is a Separately-Compiled Chunk
Every chunk so far has held one flat program. A function needs its own chunk — its body is a separate unit of code that only starts running when called, possibly many times, possibly never.
Two things worth noticing. First, a WispFunction is just another value in a constant pool — the same mechanism that already stores numbers and strings now stores whole compiled programs. Second, the function's own Compiler starts at scope_depth=1, not 0 — which means Chapter 4's own if scope_depth == 0: global check is automatically false for everything declared inside a function body, parameters included. A function's own locals are locals from the very first line, with no extra scoping code needed.
The Call Convention: Function, Then Arguments, on the Stack
By the time OP_CALL runs, the stack looks like [..., function, arg0, arg1, ...] — the function value sitting just below its own arguments.
Call Frames: an Explicit List, Not Python's Own Stack
This is the actual fix. Instead of the VM calling a Python function to run a Wisp function (which is exactly what would tie Wisp's own depth to Python's own recursion limit), the VM keeps a plain Python list of frames, and its own run() method is one loop, reading whichever frame is currently on top.
OP_CALL doesn't call anything, in the Python sense. It appends one more CallFrame to a list and lets the same while loop keep running — the next iteration just happens to read frame.function.chunk from the newly-pushed frame instead of the caller's. Nothing about the Python call stack changes at all, no matter how many Wisp-level calls are active.
A Necessary Fix: Locals Are Frame-Relative Now
Chapter 4's own OP_GET_LOCAL/OP_SET_LOCAL read stack[slot] directly — correct back then, because only one frame (the top-level script) ever existed, so a local's slot number and its actual stack position were the same number. With more than one frame possibly active, that's no longer true.
OP_GET_LOCAL to Chapter 4's own raw stack[slot] and running fun identity(n) { return n; } print identity(42);: the result is <fn identity> — the function object itself, not 42. Slot 0 should mean "the first local in this frame," but a raw index reads absolute position 0 in the whole stack, which is where the function value itself is still sitting (arguments are pushed after it, so the parameter n is actually at absolute position 1, not 0). No crash, no error — a completely wrong, confidently printed answer.
The fix is one word of arithmetic in two places:
frame.base was set the moment the call happened, to len(stack) - arg_count — exactly the position of the first argument, which is exactly where slot 0 is supposed to point. Every local reference now resolves relative to whichever frame is currently active, so the identical slot number 0 correctly means something different in every simultaneously-active call.
OP_RETURN's Real Meaning
Through Chapter 5, OP_RETURN just meant "the program is done, hand back whatever's on top of the stack" — true only because there was ever exactly one chunk running. Now it has to actually tear down a frame and hand control back to whoever made the call.
finished_frame.base - 1 is deliberate — it removes the callee's own function value too, not just its arguments and locals, since that value has no further use once the call is over. What's left afterward is exactly what a normal expression evaluation expects: one value, sitting where the call itself used to be.
fun add(a, b) { return a + b; } print add(3, 4); runs to ['7']. fun noReturn() { print 1; } var result = noReturn(); print result; runs to ['1', 'nil'] — falling off the end returns the implicit nil this chapter's own compiler always appends. A return two levels deep inside a while/if unwinds correctly too: fun findFirstOver(limit) { var i=0; while (true) { if (i > limit) { return i; } i = i + 1; } } called with 5 returns 6.
The Payoff: 50,000 Levels of Real Recursion
This is what all of it was for.
fun sumTo(n) { if (n < 1) { return 0; } return n + sumTo(n - 1); } — called with n = 10000: 50005000, correct, roughly 61× past Course 1 Chapter 7's own measured 163-level ceiling and roughly 20× past this course's own Chapter 1 measured 497-level tree-walking-eval ceiling. Pushed to n = 50000: 1250025000, correct, completing in 0.211 seconds. Python's own sys.getrecursionlimit() (1000 by default) never enters into it at all — VM.run() is one while loop, and every one of those 50,000 nested Wisp calls is just one more CallFrame appended to a Python list, not one more Python stack frame.
Multiple Functions, Not Just Recursion
a(5) calls b(6) calls c(7), computing 7 * 2: 14. Nothing about the frame mechanism is specific to a function calling itself — self.frames simply accumulates one entry per active call, whether that's the same function repeatedly or three unrelated ones.
RuntimeError: Expected 2 arguments but got 1. Calling a plain number as if it were a function: RuntimeError: Can only call functions, got float. Both checks run in OP_CALL, before a new frame is ever pushed — a bad call never gets the chance to corrupt the frame stack.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| 50,000-level recursion, zero Python recursion | Course 1, Chapter 7's own 163-level ceiling and this course's own Chapter 1, 497-level ceiling — both directly, finally resolved, exactly as Chapter 1 forward-referenced |
Local slots are frame-relative (frame.base + slot), not raw stack indices | Chapter 4's own single-frame simplification, now revised — and a real, silent, verified bug (a function value printed instead of a number) showing exactly why the revision was necessary, not cosmetic |
| A function value is just another constant-pool entry | Chapter 2's own constant pool, which already had to hold arbitrary Python values — this chapter is the first time that generality actually gets used for something other than a number or a name |
| Global name resolution happens at runtime, not compile time (Chapter 4) | What makes mutual recursion possible at all — a function can reference another function that hasn't been compiled yet, as long as it exists by the time the call actually runs |
Hands-On Exercises
Compile and run fun add(a, b) { return a + b; } print add(3, 4); using this chapter's own Compiler/VM. Trace, by hand, the exact contents of self.stack immediately before OP_CALL runs, immediately after the new CallFrame is pushed, and immediately after OP_RETURN finishes — including the value of frame.base for the add call.
Revert OP_GET_LOCAL/OP_SET_LOCAL to Chapter 4's own raw stack[slot] addressing (no frame.base offset), and run fun identity(n) { return n; } print identity(42); through the reverted VM. Confirm the wrong result this chapter's own warn-box reports, and explain precisely which value ends up sitting at absolute stack position 0 at the moment identity's own body runs, and why.
Compile and run two mutually recursive functions — isEven and isOdd, each calling the other, with isEven declared first — checking isEven(10) and isOdd(10). Explain why this works even though isEven's own body references isOdd by name before isOdd has been declared anywhere yet, tying your answer directly to a specific design decision from Chapter 4.
Chapter 6 Quick Reference
WispFunction: name, arity, and its own separately-compiledChunk— stored as just another constant-pool value- Call convention: function value, then each argument, pushed onto the shared stack;
OP_CALL <arg_count> CallFrame: function +ip+base— a plain Python list append/pop, never a Python function call- Verified fix: locals must be
frame.base + slot, not rawstack[slot]— reverting it silently returned a function object instead of a number - THE PAYOFF, verified: 50,000 levels of real recursive Wisp calls in 0.211s — ~300× past Course 1's own 163-level ceiling, ~100× past this course's own Chapter 1 ceiling, bounded only by memory
- Verified: arity mismatches and non-callable values both fail with a clean
RuntimeError, checked before any frame is pushed - Next chapter: Closures & Upvalues — the genuinely hard problem of a local variable that needs to outlive the function call that created it
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
Garbage Collection: Mark-and-Sweep
Writing a Compiler/Interpreter: Advanced
Chapter 8 · Garbage Collection: Mark-and-Sweep
Chapter 7 gave objects a way to outlive the call that created them — a closure keeps its captured upvalues alive indefinitely, for as long as anything still needs them. Nothing so far has ever gotten rid of one. Closure and Upvalue objects have been piling up in memory since Chapter 6, with nothing ever asking whether a given one is still needed. This chapter builds a real collector to answer that question — and along the way, finds a genuinely surprising reason it's needed at all, in a VM hosted inside a language that already has its own garbage collector.
An Honest Framing, First
malloc'd Closure stays allocated forever unless something explicitly frees it. This course's own VM is written in Python, which already reference-counts every object and reclaims it automatically the instant nothing points to it anymore. Building a mark-and-sweep collector here is a genuine, correctly-working algorithm — not a simulation — but it's worth being direct about why: it's the real mechanism a systems language needs, demonstrated faithfully, even though Python's own memory management would keep this specific VM safe with or without it. Whether that makes this chapter's own collector purely redundant turns out to have a more interesting answer than "yes" — see below.
What's on the Heap
Only two kinds of object get created dynamically, during execution, potentially many times, with a genuine question of whether each one is still needed later: Closure and Upvalue. Both allocation points from Chapter 7 now record into an explicit self.heap list.
Roots: Where Reachability Starts
An object is garbage only if nothing reachable from the program's own currently-live state points to it, directly or indirectly. That live state — the roots — is everything currently on the value stack, every value stored in globals, the closure each active call frame is running, and any upvalue still open (backing a live stack slot right now).
Mark: Following References Transitively
A root is only the start. A closure can hold upvalues; a closed upvalue can hold a value that's itself a closure (a closure captured by another closure, stored as its own upvalue's frozen value). Marking has to follow those references outward, not just mark the roots themselves.
Sweep: Discarding What Isn't Marked
Verified: a Real Collection Cycle
collect_garbage(): 8 objects on the heap — the top-level script's own closure, makeAdder's closure, and one [Closure, Upvalue] pair for each of the three calls. After: 3 survive — makeAdder's own closure, keep's closure, and keep's captured upvalue. 5 collected: the two throwaway adder closures from makeAdder(20) and makeAdder(30) (each immediately discarded by the very next OP_POP, per Chapter 4's own expression-statement rule), their two upvalues, and — genuinely unexpected until checked — the top-level script's own closure, which nothing references anymore once the program has finished running.
keep(100) again, after collect_garbage() has already run: 110, correct. Removing an object from self.heap is bookkeeping only — the real Python object keep refers to was never touched.
makeAdder results in separate globals (a, b, c) instead of discarding two of them: collect_garbage() removes exactly 1 object — again, only the now-unreferenced top-level script closure. Every closure and upvalue the program itself created stays reachable through globals, and none of it is touched.
The Twist: This Collector Isn't Actually Redundant
The honest framing at the top of this chapter said Python would keep this VM memory-safe with or without a collector of its own. Checking that directly turns up something worth correcting.
sys.getrefcount() on one of the two discarded adder closures before collect_garbage() runs shows a live reference count greater than zero — self.heap itself is holding a genuine, counted Python reference to an object nothing in the running Wisp program cares about anymore. Only after collect_garbage() removes it from self.heap does that reference actually go away.
self.heap only ever grows via append() in alloc_closure/capture_upvalue; nothing removes anything from it except collect_garbage() itself. Without ever calling it, every closure and upvalue a Wisp program has ever created — including every one immediately discarded, like the two adder closures above — would stay referenced by self.heap forever, growing without bound for the entire life of the program. Python's own reference counting was never the obstacle; the VM's own tracking structure, built specifically so a mark-and-sweep collector would have something to sweep, is what turns out to need exactly the collector this chapter built to fix it.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Roots include every active frame's own closure, and every open upvalue | Chapter 6's own CallFrame and Chapter 7's own open_upvalues dict — this chapter reuses both structures directly as sources of truth for reachability, adding nothing new to track |
| A closed upvalue can hold a closure, which mark_object follows transitively | Chapter 7's own closure-capturing-a-closure scenarios (Exercise 3's nested forwarding) — the same shapes that made closures hard to compile make them non-trivial to trace for reachability too |
The VM's own self.heap list is itself a real reference, not neutral bookkeeping | A general lesson about instrumentation: adding a tracking structure to observe a system can change that system's own behavior — here, literally introducing the memory leak this chapter's own collector then fixes |
| A no-garbage program collects almost nothing (just the finished top-level script) | Confirms the collector is conservative and correct in the boring case, not just the dramatic one — a collector that swept something still reachable would be a far worse bug than one that's merely unnecessary |
Hands-On Exercises
Before running this chapter's own makeAdder example, predict by hand exactly how many objects will be on vm.heap once the program finishes (counting every Closure and Upvalue allocated by name), and which specific ones will survive a collect_garbage() call. Then run it and check your prediction against the actual heap contents.
Using sys.getrefcount(), confirm directly that one of the two discarded adder closures from this chapter's own example still has a positive Python reference count immediately before collect_garbage() runs, and explain specifically which line of code is responsible for that reference existing at all.
Rewrite this chapter's own makeAdder example so that all three calls' own results are stored in separate globals (a, b, and c) instead of two being discarded, then call all three. Run collect_garbage() and confirm how many objects it actually removes. Explain why the number isn't zero, even though nothing the program created is unreachable.
Chapter 8 Quick Reference
- Heap: every
ClosureandUpvalueever allocated, tracked inself.heapfrom the moment each is created - Roots: the value stack,
globals, each active frame's own closure, and every currently open upvalue - Mark: transitive — a closure marks its own upvalues; a closed upvalue marks a closure it happens to hold
- Sweep: anything on the heap left unmarked after
mark_roots()is removed; every mark resets for the next cycle - Verified: a real program with genuine garbage — 8 objects, exactly 5 correctly collected, exactly 3 correctly kept and still functional afterward
- Verified: a program with nothing genuinely unreachable collects almost nothing (just the finished top-level script's own closure)
- The twist, verified:
self.heapitself holds a real, counted Python reference — without this chapter's own collector, that append-only list would leak every discarded closure and upvalue for the life of the program, regardless of Python's own reference counting - Next chapter: Classes, Instances & Method Dispatch in Bytecode — the last major feature, verified against Course 1's own tree-walking dispatch
Classes, Instances & Method Dispatch in Bytecode
Writing a Compiler/Interpreter: Advanced
Chapter 9 · Classes, Instances & Method Dispatch in Bytecode
Course 1, Chapter 8 built classes on top of what its own tree-walking interpreter already had — a callable value (calling a class constructs an instance, exactly like calling a function runs its body), plus this bound via a fresh Environment and a fresh WispFunction on every single property access. This chapter builds the same feature set — declaration, instantiation, fields, methods, single inheritance — on top of what this course already has: chunks, call frames, locals, and closures. The mechanism ends up genuinely lighter, and one real bug surfaced while getting there.
Three New Runtime Types
find_method is line-for-line the same recursive walk Course 1's own WispClass used — this chapter changes nothing about how inheritance is resolved, only how the resulting method gets bound to a specific instance once found.
this Is Just Slot 0 — No Special-Casing Anywhere
Course 1's own chapter made a point of it: this "isn't a keyword with special evaluation rules — it's parsed straight into Variable('this')." This chapter takes that literally. Compiling a method declares a synthetic local named "this" before its real parameters:
Every reference to this inside a method body now resolves through resolve_local — the exact same code Chapter 4 wrote for any ordinary local, and Chapter 7's resolve_upvalue for a nested function closing over this. Nothing new had to be built for name resolution at all.
A Real Bug: Methods Need a Different Frame Base Than Functions
The first attempt reused Chapter 6's own call convention unchanged: new_base = len(stack) - arg_count, with the receiver simply overwriting the callee's own stack slot the way Chapter 6 never needed to. Running this.name = name; inside init immediately raised RuntimeError: Only instances have fields.
OP_GET_LOCAL 0 at the first real argument instead of the receiver — for init, that meant this resolved to whatever value init's own parameter happened to be, not an instance at all.
The fix: a CallFrame tracks two positions, not one — base (where slot 0 actually lives, used by OP_GET_LOCAL/OP_SET_LOCAL) and origin (the callee's own original position, used only to know how much to discard on return). For a plain function call, base = origin + 1. For a method or constructor call, base = origin — the receiver is slot 0.
class Greeter { init(name) { this.name = name; } greet() { return "hello, " + this.name; } } var g = Greeter("wisp"); print g.greet(); runs to ['hello, wisp'], matching Course 1's own verified result for the identical program.
Compiling a Class Declaration
OP_CLASS pushes a new, empty class. An optional superclass expression is compiled and consumed by OP_INHERIT. Each method compiles through the same compile_function used above, wrapped in OP_CLOSURE (methods can capture upvalues too, exactly like any nested function), then attached via OP_METHOD — all while the class itself sits on the stack, untouched, underneath.
Property Access: Get and Set
That last line is the whole method-binding mechanism. No new environment, no new closure, no re-wrapping of anything — just a receiver and a method, held together. a.increment() compiles as an ordinary Get feeding an ordinary Call: nothing about method calls needed a dedicated opcode at all, since a BoundMethod flows through the exact same OP_CALL every other callable value uses.
Verified: Independent Instances, and a Bug This Design Can't Reproduce
Counter example — two instances, two stored bound methods, called out of order: incA(), then incB() — runs to ['1', '101'], exactly matching Course 1's own correct result. Course 1's own chapter separately documented a real bug in an earlier, buggy version of its own bind() — mutating one shared closure in place instead of creating a fresh one, collapsing two instances' bound methods into one (['101', '102']). That specific failure mode has no equivalent here at all: OP_GET_PROPERTY allocates a brand-new BoundMethod object on every single access, unconditionally — there is no shared, mutable object anywhere in this design for two instances to accidentally collide on.
Dog < Animal overrides speak; Cat < Animal doesn't. d.speak()/c.speak(): ['Rex barks.', 'Whiskers makes a sound.']. A three-level chain, C < B < A, with only A defining whoAmI: C().whoAmI() correctly returns "A", found two levels up.
init Always Returns this — Decided at Compile Time
Course 1 solved this at runtime: WispFunction.call() checks self.is_initializer after catching a ReturnException and substitutes this for whatever the return actually carried. This chapter's own compiler can make the identical guarantee earlier — by never compiling the return value the programmer wrote in the first place.
class Weird { init(x) { this.x = x; return "ignored"; } } var w = Weird(42); print w.x; runs to 42. The string "ignored" is never even compiled — self.visit(node.value) is never called for it — rather than being computed and then discarded at runtime the way Course 1's own is_initializer substitution does it.
A Fair, Isolated Performance Comparison
Chapter 1 already established that a naive Python-hosted VM doesn't automatically outrun a tree-walking interpreter — comparing two entirely different execution models is easy to get wrong. A fairer question for this chapter specifically: within the same VM, how much does the lightweight BoundMethod pairing actually save over allocating a fresh closure the way Course 1's own bind() does — an Environment-equivalent plus a WispFunction-equivalent, every single property access?
BoundMethod(receiver, method) pair — 0.098 µs/access. A fresh Upvalue plus a fresh Closure wrapping the same method — mirroring the shape of Course 1's own "new environment, new function object" cost, without a full cross-architecture comparison's other confounding variables — 0.223 µs/access. A 2.27× difference, isolating just this one design choice.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
this resolves through ordinary resolve_local/resolve_upvalue | Chapters 4 and 7's own name-resolution machinery, reused completely unchanged — the single biggest reason this chapter needed no new resolution logic at all |
Methods and constructors need base = origin, not base = origin + 1 | Chapter 6's own call convention, revised a second time (the first was Chapter 7's frame-relative local addressing) — a real bug, found and fixed the same way, by actually running the code rather than assuming the existing formula would generalize |
A BoundMethod is immune, by construction, to Course 1's own over-mutation bug | Course 1, Chapter 8's own found-and-fixed bug — not avoided by extra caution here, but structurally impossible, since nothing is ever mutated in place |
a.increment() needs no dedicated method-call opcode | Chapter 6's own OP_CALL, which already had to handle multiple callee types — a BoundMethod is simply a third kind, alongside Closure and WispClass |
Hands-On Exercises
Compile and run class Counter { init(startAt) { this.count = startAt; } increment() { this.count = this.count + 1; return this.count; } } var a = Counter(5); print a.increment();. Hand-trace the stack contents and the values of origin and base for both the Counter(5) call and the a.increment() call, showing exactly which stack position holds this in each.
Reproduce this chapter's own isolated allocation-cost comparison (a BoundMethod pair versus a fresh Upvalue + Closure pair, 20,000 times each). Confirm the ratio lands close to this chapter's own reported 2.27×, and explain in your own words why the heavier version's cost comes specifically from object allocation rather than from anything about method dispatch logic itself.
Using class A { whoAmI() { return "A"; } } class B < A {} class C < B {}, instrument WispClass.find_method to count its own recursive calls, then run C().whoAmI(). Determine the total call count, and explain why it isn't simply 1 (for finding whoAmI) plus 1 (for the init lookup during construction) — trace through exactly what each recursive step costs.
Chapter 9 Quick Reference
- New types:
WispClass(callable, constructs instances),WispInstance(afieldsdict),BoundMethod(receiver + method, nothing more) this: a synthetic local at slot 0 of every method — resolved through Chapter 4/7's own existing machinery, no special-casing- Real bug, found and fixed: methods and constructors need
base = origin(receiver is slot 0), not Chapter 6's ownbase = origin + 1— reusing the old formula silently pointedthisat the wrong stack position - Verified: Greeter, independent-instance Counter, and Animal/Dog/Cat inheritance all match Course 1's own correct results exactly
- Structural immunity: Course 1's own over-mutation bug (bound methods collapsing across instances) cannot occur here — every property access allocates a fresh
BoundMethod, unconditionally init: forced to returnthisat compile time — an explicit conflictingreturnvalue is never even compiled, not just overridden at runtime- Verified — fair, isolated comparison: a lightweight
BoundMethodpair is 2.27× cheaper per access than allocating a fresh closure the way a tree-walking interpreter's ownbind()does - Next chapter: Capstone — benchmarking this finished VM against Course 1's own tree-walking interpreter, on the same program
Capstone — Benchmarking Wisp: Tree-Walking vs. Bytecode VM
Writing a Compiler/Interpreter: Advanced
Chapter 10 · Capstone: Benchmarking Wisp — Tree-Walking vs. Bytecode VM
Course 1 finished with a working tree-walking interpreter and one deliberate, unresolved promise: "the language stays the same. How it runs changes completely." This course spent nine chapters keeping that promise — a compiler, a stack-based VM, call frames, closures, garbage collection, classes. This capstone runs Course 1's own capstone program, unchanged, through everything Course 2 built, confirms it produces identical output, and then finally does the one thing Chapter 1 could only forward-reference: a real, honest, measured answer to whether the bytecode VM this course spent nine chapters building is actually faster.
Two Small, Honest Gaps — Closed Right Where They're Needed
Course 1's own capstone program uses >= (checking a task's priority against a threshold) and != (checking a linked-list node against nil). Neither exists in this course's own VM — Chapter 5 only ever built <, >, and ==, because nothing before now needed the other two. Two more comparison opcodes, OP_GREATER_EQUAL and OP_NOT_EQUAL, close both gaps — the same pattern this course has followed throughout: build what's needed when something concrete needs it, not before.
The Same Program, Verified Byte-for-Byte Identical
Course 1's own Task/UrgentTask/TaskNode/TaskQueue program — a linked-list task queue built entirely from classes, single inheritance overriding describe(), a closure factory (makeUrgencyChecker) capturing a threshold, and a higher-order function (countMatching) taking a callback — compiled here exactly as written, no changes, through this course's own Chapters 2-9 pipeline.
All tasks: / 4 / - URGENT: Fix the leak / - Read a book / - URGENT: Submit tax filing / - Water the plants / Critical tasks: / 2 / Priority >= 2: / 3 — matching Course 1's own published capstone output exactly. A freshly-built, faithful tree-walking interpreter, implementing Course 1's own Environment, WispFunction.bind(), and WispClass.find_method() exactly as documented and run against the identical AST, produces the identical output too. Three independent builds of the same language — Course 1's own original, a fresh from-scratch tree-walker built for this comparison, and this course's own bytecode VM — agree completely.
The Real Comparison: Scaling Up
Course 1's own capstone program runs once, on four tasks — too small to measure anything meaningful. A scaled version — 2,000 tasks, alternating Task and UrgentTask instances, then one countMatching pass using a real closure — exercises class instantiation, inheritance, property access, and closures at a scale actually worth timing.
Not Every Workload Behaves the Same Way
Chapter 1 of this course measured the opposite result — a single, deeply-nested arithmetic expression (201+ nodes), evaluated repeatedly, ran roughly 1.7× faster under the bytecode VM than under tree-walking. That finding and this chapter's own 0.49× result aren't in conflict — they're measuring genuinely different things.
sum = sum + i; i = i + 1;, repeated 4,000 times — runs to 0.0096s under tree-walking and 0.0207s under bytecode: a ratio of 0.465×, the tree-walker still faster. Chapter 1's own crossover was specifically about expression-nesting depth within a single evaluation — the tree-walker's own recursive dispatch cost compounding with how deep one expression tree goes. Repeating a shallow expression many times, in a loop, never triggers that same compounding; each iteration's own dispatch stays cheap regardless of how many times the loop runs.
| Workload shape | Result | Why |
|---|---|---|
| One deeply-nested expression (Chapter 1, 201+ nodes) | Bytecode ~1.7× faster | Tree-walking recursion depth compounds with nesting depth; the VM's flat instruction loop doesn't |
| A shallow loop body, repeated 4,000 times | Tree-walk ~2.1× faster | No nesting depth to compound against — each iteration's dispatch stays cheap either way, and the VM pays extra per-opcode overhead the tree-walker doesn't |
| 2,000 classes/closures/method calls (this chapter) | Tree-walk ~2.0× faster | Every property access and method call costs several bytecode instructions (OP_GET_PROPERTY, OP_CALL, frame setup) where the tree-walker needs one direct Python method call |
The honest conclusion isn't "bytecode is faster" or "bytecode is slower" — it's that performance in a Python-hosted VM depends on the specific shape of the work, and only one of the three shapes this course actually measured favors the design it spent nine chapters building. That's not a disappointing result to report; it's the same rigor this course applied to every other performance claim, from Chapter 1's own first honest finding onward, held to at the very end instead of quietly abandoned for a triumphant close.
Where Every Piece Came From
| Capstone component | Chapter |
|---|---|
| Reusing Course 1's own lexer, parser, and AST unchanged | Chapter 1 — "nothing about the front end needs to be rewritten" |
| Chunks, opcodes, the fetch-decode-execute loop | Chapter 2 |
| The single-pass compiler walking the same AST | Chapter 3 |
TaskQueue's own fields, this.head/this.count | Chapter 4 (globals/locals) — extended by Chapter 9 for instance fields |
The while loop walking the linked list; >=/!= closed here | Chapter 5 |
countMatching and makeUrgencyChecker's own recursive-safe calls | Chapter 6 |
tally's closure over count and checker | Chapter 7 |
Every Closure/Upvalue this program allocates | Chapter 8 (tracked; nothing here was ever actually swept, since the whole program stays reachable throughout) |
Task/UrgentTask/TaskNode/TaskQueue, inheritance, this, bound methods | Chapter 9 |
What This Course Doesn't Cover
Course 2's own VM inherits every scope boundary Course 1 already documented (no arrays, no string formatting, no super.method(), no break/continue, no static members, no standard library beyond print) and adds a few of its own: no line-numbered error reporting or multi-frame stack traces (Course 1's own Chapter 9 was never rebuilt at the bytecode level — a runtime error here is a plain Python exception, with none of Course 1's own [line N] attribution or call-stack trace), no automatic GC triggering (Chapter 8's own collector runs only when explicitly called, never on an allocation threshold the way a real VM would), and no bytecode-level optimizations a production VM would add (constant folding, inline caching, or clox's own OP_INVOKE fast path for method calls, which Chapter 9 named directly and chose not to implement). None of these are oversights — each is a deliberate line drawn so the course stayed teachable rather than becoming a production language runtime.
Closing Both Courses Together
Twenty chapters, two courses, one language. Course 1 built a real interpreter and measured its real limits — 163 levels of recursion, a visitor pattern that beat isinstance chains, a division-by-zero bug caught by actually testing it. Course 2 rebuilt the same language's runtime from the ground up and measured its own real limits in turn — 50,000 levels of recursion where Course 1 found 163, a genuine memory leak in the collector's own bookkeeping, a lightweight method-binding design 2.27× cheaper than the alternative, and, at the very end, an honest admission that the faster design isn't uniformly faster. Nothing in either course was asserted without being run first.
Course 2 Complete — Advanced Quick Reference
- Ch.1: naive same-language bytecode is slower at small scale, faster past ~200 nodes of deep nesting; double-dispatch tree-walking costs ~2× the recursion depth of a single function
- Ch.2: a real
Chunk(bytearray + constant pool + line array), 1.56× more compact than a tuple sketch; a verified 256-constant ceiling - Ch.3: a real compiler over Course 1's own AST — the compiler trusts the tree; precedence correctness is the parser's job alone
- Ch.4: globals (name-keyed, runtime) vs. locals (slot-indexed, compile-time) — a real but modest ~5% speed advantage for locals in Python
- Ch.5: backpatched jumps for
if/while; a verified 79/80-statement crossover proving 2-byte jump offsets are necessary, not cautious - Ch.6: explicit
CallFrames replace Python's own call stack — 50,000 levels of real recursion, resolving Course 1's own 163-level ceiling - Ch.7:
Upvalues that open (live) then close (a private copy) — reproducing, faithfully, the classic loop-closure bug real JavaScript has too - Ch.8: a real mark-and-sweep collector whose own bookkeeping list turned out to be the actual memory leak it exists to fix
- Ch.9: classes and methods via a lightweight
BoundMethodpair, structurally immune to Course 1's own found over-mutation bug - Ch.10: the same program, byte-for-byte identical across three implementations — and an honest answer, not the expected one, on which is faster