⚙️

Writing a Compiler/Interpreter: Advanced

Rebuilding Wisp's Runtime — A Bytecode Compiler & Virtual Machine, From Scratch

Topics covered:
Chunks, opcodes & a stack-based VM · a single-pass bytecode compiler
Globals & locals · backpatched jumps for control flow
Explicit call frames · closures & upvalues · mark-and-sweep garbage collection
Classes & lightweight method dispatch

Capstone: Course 1's own task-queue program, verified byte-for-byte identical across three independent implementations, then a real, measured performance comparison
Exercises: 30 hands-on exercises with worked, verified solutions
Format: A4 · Dark-theme code examples
Philip Osztromok · Generated with Claude

Table of Contents

  1. Why Bytecode? From Tree-Walking to a Virtual Machine
  2. Chunks, Opcodes & a Stack-Based VM
  3. Compiling Expressions to Bytecode
  4. Global and Local Variables in Bytecode
  5. Control Flow & Jumps in Bytecode
  6. Functions & Call Frames
  7. Closures & Upvalues in a Bytecode VM
  8. Garbage Collection: Mark-and-Sweep
  9. Classes, Instances & Method Dispatch in Bytecode
  10. Capstone — Benchmarking Wisp: Tree-Walking vs. Bytecode VM
Chapter 1 of 10

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.

StageCourse 1 (Fundamentals)Course 2 (this course)
Lexing & parsingUnchanged — reused exactly as built in Course 1, Chapters 1-3
After the ASTThe Interpreter walks the tree directly, recursively, every time the program runsA compiler walks the tree once, producing a flat list of instructions
ExecutionPython's own call stack, one frame per AST node visitedA small stack-based virtual machine: one flat loop, an explicit data stack, no recursion per instruction
Function callsA 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)
MemoryPython's own garbage collector, invisiblyA 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:

# Tree-walking (Course 1's own shape) class TreeWalkEvaluator: def visit_binary(self, node): left = node.left.accept(self) right = node.right.accept(self) if node.op == '+': return left + right if node.op == '-': return left - right # ... '*' and '/' the same way # Compile the SAME tree to a flat instruction list, once def compile_expr(node, out): if isinstance(node, Literal): out.append(('PUSH', node.value)) elif isinstance(node, Binary): compile_expr(node.left, out) compile_expr(node.right, out) out.append((op_name(node.op),)) # A minimal stack-based VM -- one flat loop, no recursion def run_vm(code): stack = [] for instr in code: op = instr[0] if op == 'PUSH': stack.append(instr[1]) elif op == 'ADD': b=stack.pop(); a=stack.pop(); stack.append(a+b) # ... SUB, MUL, DIV the same way return stack.pop()

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.

Verified directly — at this scale, the tree-walker is actually faster than the bytecode VM
Tree-walking: 1.051 microseconds per evaluation. Bytecode VM: 1.374 microseconds per evaluation. The VM is roughly 24% slower, not faster — the opposite of what "bytecode is faster" would predict.
This isn't a bug in the VM — it's an honest, structural fact about running bytecode in Python
Real bytecode VMs (CPython's own C-level interpreter, or a hand-written VM in C) are fast because their instruction dispatch loop compiles down to machine code — a tight, predictable loop with cheap branching. A VM written in Python, interpreting its own separate instruction format, is still running on top of Python's own interpreter the whole time. Both the tree-walker and this VM pay Python's own per-operation overhead; the VM just adds a second layer of interpretation on top, for a small 17-instruction program. Building a bytecode VM in the same host language as the tree-walker doesn't automatically buy a speedup — it has to earn it a different way.

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 sizeTree-walk (median)Bytecode VM (median)Ratio
17 nodes1.051 µs/eval1.374 µs/eval0.765× — VM slower
201 nodes27.382 µs/eval16.318 µs/eval1.678× — VM faster
601 nodes84.893 µs/eval49.620 µs/eval1.711× — VM faster
Verified directly — the VM's advantage isn't fixed, it grows with program size, and eventually flips the result entirely
At 17 nodes the tree-walker wins. At 201 nodes and beyond, the VM wins by a consistent, repeatable ~1.7× — confirmed at two different larger sizes, not a one-off measurement.
Why size changes the answer
Every node the tree-walker visits costs a real Python function call — 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.

Verified directly — double dispatch costs almost exactly double the usable recursion depth
A left-deep chain of nested 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 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.

Verified directly — a flat instruction loop is never at risk, at any depth, because it never recurses per node
A tree 5,000 levels deep is compiled once (using a temporarily raised recursion limit just for that one compile pass — the compiler itself is still recursive, but only runs a single time) into 10,001 flat instructions. Running that same precompiled bytecode through 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.
Forward reference — this is exactly what Chapter 6 fixes for function calls
The 163-level ceiling Course 1 measured for recursive Wisp functions exists because each nested call allocates a new Python stack frame via Python's own 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 findingWhat 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 functionExplains, 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 depthChapter 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 1Chapter 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

ChapterBuilds
2Chunks, opcodes, and a minimal fetch-decode-execute VM loop
3A single-pass compiler emitting bytecode directly from Wisp source
4Global and local variables — locals resolved to stack slots at compile time
5Control flow via jump instructions and backpatching
6Functions and call frames — the fix for this chapter's own 163-level finding
7Closures and upvalues
8A real mark-and-sweep garbage collector
9Classes, instances, and method dispatch in bytecode
10Capstone — benchmarking the finished VM against Course 1's own tree-walking interpreter, on the same program

Hands-On Exercises

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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 before RecursionError; 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
Chapter 2 of 10

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.

class Chunk: def __init__(self): self.code = bytearray() # the instructions -- raw bytes self.constants = [] # actual Wisp values (floats, etc.) self.lines = [] # one entry per byte in code def write(self, byte, line): self.code.append(byte) self.lines.append(line) def add_constant(self, value): self.constants.append(value) return len(self.constants) - 1 # the index the instruction will reference

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

OP_CONSTANT, OP_ADD, OP_SUBTRACT, OP_MULTIPLY, OP_DIVIDE, OP_NEGATE, OP_RETURN = range(7)

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.

def emit_constant(chunk, value, line): idx = chunk.add_constant(value) chunk.write(OP_CONSTANT, line) chunk.write(idx, line)
Verified directly — the real chunk format is meaningfully more compact than Chapter 1's own tuple sketch, for the identical program
Encoding the same 17-node expression from Chapter 1 (((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.

Verified directly — the ceiling is exactly 256, and Python's own bytearray enforces it for free
Calling 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.
A deliberate scope simplification, not an oversight
Real VMs solve this with a second, wider instruction (clox, the C-based bytecode VM this course's design is closest to, adds an 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.

def disassemble_instruction(chunk, offset): same_line = offset > 0 and chunk.lines[offset] == chunk.lines[offset-1] line_str = " |" if same_line else f"{chunk.lines[offset]:4d}" instr = chunk.code[offset] name = OPCODE_NAMES.get(instr, f"UNKNOWN({instr})") if instr == OP_CONSTANT: const_idx = chunk.code[offset+1] value = chunk.constants[const_idx] print(f"{offset:04d} {line_str} {name:<14} {const_idx:4d} '{value}'") return offset + 2 print(f"{offset:04d} {line_str} {name}") return offset + 1

Hand-building a chunk for -((1.2 + 3.4) / 2), all on source line 3, and disassembling it:

== test chunk == 0000 3 OP_CONSTANT 0 '1.2' 0002 | OP_CONSTANT 1 '3.4' 0004 | OP_ADD 0005 | OP_CONSTANT 2 '2.0' 0007 | OP_DIVIDE 0008 | OP_NEGATE 0009 | OP_RETURN
Verified directly — the offsets and the repeated-line marker both behave correctly
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.

def run_vm(chunk): stack = [] ip = 0 code, constants = chunk.code, chunk.constants while ip < len(code): instr = code[ip]; ip += 1 if instr == OP_CONSTANT: idx = code[ip]; ip += 1 stack.append(constants[idx]) elif instr == OP_ADD: b = stack.pop(); a = stack.pop(); stack.append(a + b) # ... OP_SUBTRACT, OP_MULTIPLY, OP_DIVIDE the same shape elif instr == OP_NEGATE: a = stack.pop(); stack.append(-a) elif instr == OP_RETURN: return stack.pop() if stack else None return stack.pop() if stack else None
Verified directly — hand-assembling Chapter 1's own expression on the real chunk format reproduces its exact result
Emitting the same 17-node expression ((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.

A genuine mistake, made while building this exact test, worth keeping
The first attempt to corrupt the 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.
Verified directly — corrupting the real OP_ADD produces a silently wrong answer, not a crash
Corrupting the genuine 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:

elif instr == OP_RETURN: return stack.pop() if stack else None else: raise RuntimeError(f"Unknown opcode: {instr} at offset {ip-1}")
Verified directly — the same corrupted program now fails loudly, and correct programs are unaffected
With the guard in place, corrupting 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 findingWhat it connects to
Chunks are hand-assembled here, one instruction at a timeChapter 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 arrayChapter 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 guardBoth 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 loopChapter 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

Chapter 2 Quick Reference

  • Chunk: a bytearray of 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 for OP_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.0 result
  • 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: raise guard 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
Chapter 3 of 10

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.

# Course 1, Chapter 3 -- the real AST node classes this compiler compiles @dataclass class Binary(Expr): left: Expr operator: str # not 'op' -- this is the real field name right: Expr line: int

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.

BINARY_OPS = {'+': OP_ADD, '-': OP_SUBTRACT, '*': OP_MULTIPLY, '/': OP_DIVIDE} class Compiler: def __init__(self): self.chunk = Chunk() def compile(self, expr): self.visit(expr) self.chunk.write(OP_RETURN, expr.line) return self.chunk def visit(self, node): node.accept(self) def visit_literal(self, node): idx = self.chunk.add_constant(node.value) self.chunk.write(OP_CONSTANT, node.line) self.chunk.write(idx, node.line) def visit_grouping(self, node): self.visit(node.expression) # no instruction of its own -- see below def visit_unary(self, node): self.visit(node.right) self.chunk.write(OP_NEGATE, node.line) def visit_binary(self, node): self.visit(node.left) self.visit(node.right) self.chunk.write(BINARY_OPS[node.operator], node.line)

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

Verified directly — Course 1, Chapter 3's own published example tree, compiled and run for the first time
Course 1's own Chapter 3 printed this exact tree for "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.
Verified directly — this course's own larger 17-node expression, rebuilt as a real AST and compiled
The expression from Chapters 1-2 (((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).
Verified directly — Unary compiles and runs correctly, matching Chapter 2's own hand-assembled result
-(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.

Verified directly — a Grouping-wrapped expression compiles to byte-identical bytecode as the unwrapped version
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.
This is exactly why Grouping existed in the first place
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.

Verified directly — a deliberately mis-shaped tree compiles without a single complaint, and produces a confidently wrong answer
Hand-building a tree for "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.
This is a real, working division of responsibility, not a gap
Precedence correctness is entirely Course 1, Chapter 2's own job — the parser is where * 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 findingWhat it connects to
The compiler reuses Course 1's own lexer and parser, unchangedThe 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 jobChapter 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 nodeChapter 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

Chapter 3 Quick Reference

  • Field name correction: Course 1's real AST uses Binary.operator, not the shorthand op Chapters 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 to 14.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.0 instead of 14.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
Chapter 4 of 10

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

OpcodeDoes
OP_POPDiscard the top of the stack — needed for expression statements, and for locals leaving scope
OP_DEFINE_GLOBALPop a value, store it in the VM's own globals table under a name
OP_GET_GLOBALLook up a name in globals, push the result
OP_SET_GLOBALOverwrite an existing entry in globals — the new value stays on the stack
OP_GET_LOCALPush a copy of whatever's already sitting at a specific stack slot
OP_SET_LOCALOverwrite 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.

def visit_var_stmt(self, node): self.visit(node.initializer) if self.scope_depth == 0: idx = self.chunk.add_constant(node.name) # the NAME, as a constant self.chunk.write(OP_DEFINE_GLOBAL, node.line) self.chunk.write(idx, node.line) else: self.declare_local(node.name) # see below -- no instruction at all

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.

Verified directly — declaration, reading, and reassignment all work end to end
var x = 10; print x; compiles and runs to ['10']. var x = 10; x = 20; print x; runs to ['20'].
Verified directly — reading or assigning an undeclared global fails cleanly, matching Course 1's own rule
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.

def declare_local(self, name): self.locals.append((name, self.scope_depth)) def resolve_local(self, name): for i in range(len(self.locals) - 1, -1, -1): # innermost first if self.locals[i][0] == name: return i # the index IS the slot number return None # not a local -- must be a global

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.

elif instr == OP_GET_LOCAL: slot = code[ip]; ip += 1 stack.append(stack[slot]) # a plain list index -- no dict, no name, anywhere elif instr == OP_SET_LOCAL: slot = code[ip]; ip += 1 stack[slot] = stack[-1] # peek, don't pop -- assignment is an expression
Verified directly — the classic shadowing test, resolved entirely at compile time
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."
Verified directly — local assignment correctly updates the right slot without leaking out of its block
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.

def end_scope(self, line): self.scope_depth -= 1 while self.locals and self.locals[-1][1] > self.scope_depth: self.chunk.write(OP_POP, line) # pop it off the RUNTIME stack self.locals.pop() # AND stop tracking it at compile time

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.

Verified directly — locals are measurably, consistently faster, but the gap is modest, not dramatic
Reading the same variable 200,000 times: global (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).
Why the gap isn't bigger — a direct echo of Chapter 1's own finding
In a real bytecode VM written in C (the design this course is modeled on), a local slot access is a handful of machine instructions — an array offset, nothing more — while a global lookup means hashing a string, probing a table, and comparing keys, a genuinely much larger cost. In Python, both operations are already running through CPython's own heavily-optimized, C-implemented 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.

Verified directly — 256 simultaneously-live locals compile fine; 257 hits the exact same ValueError as Chapter 2's own constant-pool ceiling
A block declaring 256 distinct local variables (all still in scope at once) compiles without incident. The same shape with 300 locals fails immediately with 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 findingWhat it connects to
Constant-pool deduplication, reused for variable namesChapter 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 storageChapter 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 runtimeCourse 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 globalsChapter 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

Chapter 4 Quick Reference

  • Globals: a name-keyed dict on 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_local emits zero instructions
  • Verified: the classic shadowing test — var x=1; {var x=2; print x;} print x; — correctly prints 2 then 1, 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/while using instructions that overwrite their own operand byte after the fact
Chapter 5 of 10

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:

elif instr == OP_EQUAL: b = stack.pop(); a = stack.pop(); stack.append(a == b) elif instr == OP_GREATER: b = stack.pop(); a = stack.pop(); stack.append(a > b) elif instr == OP_LESS: b = stack.pop(); a = stack.pop(); stack.append(a < b) def is_truthy(value): if value is None: return False if isinstance(value, bool): return value return True

Nothing new needed for boolean or nil literalsLiteral(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.

def emit_jump(self, opcode, line): self.chunk.write(opcode, line) self.chunk.write(0xff, line) # placeholder high byte self.chunk.write(0xff, line) # placeholder low byte return len(self.chunk.code) - 2 # remember WHERE the placeholder lives def patch_jump(self, jump_pos): offset = len(self.chunk.code) - jump_pos - 2 # the real distance, now known self.chunk.code[jump_pos] = (offset >> 8) & 0xFF self.chunk.code[jump_pos + 1] = offset & 0xFF
Why the offset is two bytes, not one, from the very start
A single byte (Chapter 2's own choice for 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

def visit_if_stmt(self, node): self.visit(node.condition) then_jump = self.emit_jump(OP_JUMP_IF_FALSE, node.line) self.visit(node.then_branch) if node.else_branch is not None: else_jump = self.emit_jump(OP_JUMP, node.line) self.patch_jump(then_jump) # false lands HERE -- right at the else self.visit(node.else_branch) self.patch_jump(else_jump) # after else, land HERE -- past everything else: self.patch_jump(then_jump) # false lands HERE -- straight past the then-branch
Verified directly — both branches of an if/else, and the no-else case, all resolve correctly
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:

0000 OP_CONSTANT 0 '1.0' 0002 OP_CONSTANT 1 '2.0' 0004 OP_LESS 0005 OP_JUMP_IF_FALSE 3 -> offset 11 0008 OP_CONSTANT 2 '100.0' 0010 OP_PRINT 0011 OP_RETURN

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.

def visit_while_stmt(self, node): loop_start = len(self.chunk.code) # remember where the condition begins self.visit(node.condition) exit_jump = self.emit_jump(OP_JUMP_IF_FALSE, node.line) self.visit(node.body) self.emit_loop(loop_start, node.line) # jump BACKWARD to loop_start self.patch_jump(exit_jump) # exit lands HERE, after the loop entirely

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.

Verified directly — a counting loop runs the correct number of times and stops correctly
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:

0000 OP_CONSTANT 0 '0.0' 0002 OP_DEFINE_GLOBAL 1 name='i' 0004 OP_GET_GLOBAL 1 name='i' 0006 OP_CONSTANT 2 '2.0' 0008 OP_LESS 0009 OP_JUMP_IF_FALSE 11 -> offset 23 0012 OP_GET_GLOBAL 1 name='i' 0014 OP_CONSTANT 3 '1.0' 0016 OP_ADD 0017 OP_SET_GLOBAL 1 name='i' 0019 OP_POP 0020 OP_LOOP 19 -> offset 4 0023 OP_RETURN

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

Verified directly — an if compiled inside a while's own body patches correctly, with no cross-contamination
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

Verified directly — a real loop body exceeds 255 bytes easily, and the 2-byte encoding handles it without incident
A 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."
The exact crossover, found by binary search
A one-byte jump encoding compiles a padded loop body of 79 filler statements successfully; 80 filler statements fails. Real Wisp programs — anything with a moderately sized loop body, not a contrived stress test — cross that line far more easily than they'd ever accumulate 256 distinct constants or 256 simultaneously-live locals. A one-byte operand would have been the wrong design for this specific instruction, not merely a smaller safety margin.

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.

Verified directly — a desugared for-loop compiles and runs correctly through visit_while_stmt and visit_block_stmt alone
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 findingWhat it connects to
Backpatching: emit a placeholder, fix it once the real size is knownThe 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 1Chapters 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 codeCourse 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 loopChapter 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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_jump writes a placeholder and returns its position; patch_jump overwrites 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 if inside a while) 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 for loop 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
Chapter 6 of 10

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.

class WispFunction: def __init__(self, name, arity, chunk): self.name = name self.arity = arity # how many parameters it takes self.chunk = chunk # its OWN chunk -- a separate program
def visit_function_stmt(self, node): func_compiler = Compiler(enclosing_scope_depth=1) # starts as ITS OWN local scope for param in node.params: func_compiler.declare_local(param) # params become locals 0, 1, 2, ... for stmt in node.body: func_compiler.visit(stmt) # implicit "fell off the end" -> return nil idx = func_compiler.chunk.add_constant(None) func_compiler.chunk.write(OP_CONSTANT, node.line) func_compiler.chunk.write(idx, node.line) func_compiler.chunk.write(OP_RETURN, node.line) function = WispFunction(node.name, len(node.params), func_compiler.chunk) idx = self.chunk.add_constant(function) # the function VALUE is just another constant self.chunk.write(OP_CONSTANT, node.line) self.chunk.write(idx, node.line) # ... then OP_DEFINE_GLOBAL or declare_local, exactly like Chapter 4's var

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

def visit_call(self, node): self.visit(node.callee) # pushes the function value for arg in node.arguments: self.visit(arg) # pushes each argument, in order self.chunk.write(OP_CALL, node.line) self.chunk.write(len(node.arguments), node.line)

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.

class CallFrame: def __init__(self, function, ip, base): self.function = function self.ip = ip # THIS frame's own instruction pointer self.base = base # where THIS frame's locals begin on the shared stack # inside VM.run(): while True: frame = self.frames[-1] # whichever call is currently active code = frame.function.chunk.code # THIS frame's own bytecode instr = code[frame.ip]; frame.ip += 1 # ... dispatch, same as every earlier chapter ... elif instr == OP_CALL: arg_count = code[frame.ip]; frame.ip += 1 callee = self.stack[-(arg_count + 1)] new_base = len(self.stack) - arg_count self.frames.append(CallFrame(callee, 0, new_base)) # just a list append

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.

Verified directly — the exact, silent bug this causes if the fix is skipped
Reverting 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:

elif instr == OP_GET_LOCAL: slot = code[frame.ip]; frame.ip += 1 self.stack.append(self.stack[frame.base + slot]) # offset by THIS frame's own base elif instr == OP_SET_LOCAL: slot = code[frame.ip]; frame.ip += 1 self.stack[frame.base + slot] = self.stack[-1]

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.

elif instr == OP_RETURN: result = self.stack.pop() if self.stack else None finished_frame = self.frames.pop() if not self.frames: return result # the top-level script itself just finished del self.stack[finished_frame.base - 1:] # discard the function value, args, and locals self.stack.append(result) # leave just the return value where the call was

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.

Verified directly — declaration, parameters, return values, and the implicit-nil case all work end to end
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.

Verified directly — recursion far beyond both of this project's own previously-measured ceilings, with zero Python recursion anywhere
A recursive Wisp function summing 1 through n by calling itself — 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.
What actually bounds recursion now
Available memory for the frame list and the value stack — nothing else. This is the real, structural fix Chapter 1 forward-referenced: an explicit frame stack (a data structure the VM owns and controls) instead of borrowing the host language's own call stack (a resource the VM has no control over at all).

Multiple Functions, Not Just Recursion

Verified directly — three genuinely different functions calling each other thread through the frame stack correctly
a(5) calls b(6) calls c(7), computing 7 * 2: 14. Nothing about the frame mechanism is specific to a function calling itselfself.frames simply accumulates one entry per active call, whether that's the same function repeatedly or three unrelated ones.
Verified directly — a wrong argument count and a non-callable value both fail cleanly
Calling a 2-parameter function with only 1 argument: 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 findingWhat it connects to
50,000-level recursion, zero Python recursionCourse 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 indicesChapter 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 entryChapter 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

Chapter 6 Quick Reference

  • WispFunction: name, arity, and its own separately-compiled Chunk — 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 raw stack[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
Chapter 7 of 10

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

fun makeCounter() { var count = 0; fun increment() { count = count + 1; return count; } return increment; } var counter = makeCounter(); print counter(); // 1 print counter(); // 2

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.

class Upvalue: def __init__(self, stack, slot): self.stack = stack; self.slot = slot self.closed = False; self.value = None def get(self): return self.value if self.closed else self.stack[self.slot] def set(self, v): if self.closed: self.value = v else: self.stack[self.slot] = v def close(self): self.value = self.stack[self.slot] # copy the current value out self.closed = True # stop trusting the (soon-to-be-gone) slot

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.

def resolve_upvalue(self, name): if self.enclosing is None: return None # the top-level script has nothing to enclose it local_slot = self.enclosing.resolve_local(name) if local_slot is not None: return self.add_upvalue(local_slot, is_local=True, name=name) outer_upvalue = self.enclosing.resolve_upvalue(name) # walk OUTWARD, recursively if outer_upvalue is not None: return self.add_upvalue(outer_upvalue, is_local=False, name=name) return None

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.

elif instr == OP_CLOSURE: idx = code[frame.ip]; frame.ip += 1 function = constants[idx] upvalues = [] for _ in range(len(function.upvalue_descriptors)): is_local = code[frame.ip]; frame.ip += 1 index = code[frame.ip]; frame.ip += 1 if is_local: upvalues.append(self.capture_upvalue(frame.base + index)) # a slot of THIS call else: upvalues.append(frame.closure.upvalues[index]) # forward one THIS closure already has self.stack.append(Closure(function, upvalues))

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.

# end_scope (Chapter 4), revised: while self.locals and self.locals[-1][1] > self.scope_depth: self.chunk.write(OP_CLOSE_UPVALUE, line) # was OP_POP self.locals.pop() # OP_RETURN (Chapter 6), revised: finished_frame = self.frames.pop() self.close_upvalues(finished_frame.base) # NEW -- close everything about to vanish if not self.frames: return result del self.stack[finished_frame.base - 1:]
OP_CLOSE_UPVALUE is safe to use unconditionally
Every local leaving scope gets this instruction now, whether or not a closure actually captured it — a deliberate simplification over tracking "was this specific local ever captured" at compile time. If nothing ever captured that slot, 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

Verified directly — the counter keeps counting, three calls after makeCounter itself returned
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.
Verified directly — two calls to makeCounter produce two genuinely independent counters
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

Verified directly — a setter and a getter, capturing the same local while it's still open, genuinely share it
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.

Verified directly — three closures created across three iterations all report the same final value
Three closures, each capturing a shared loop counter 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

Verified directly — declaring a new local inside the loop's own body block gives each closure its own binding
Adding one line inside the loop body — 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

Verified directly — a variable captured through an intermediate function that never references it itself
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 findingWhat it connects to
An Upvalue switches from pointing at a stack slot to holding its own copyChapter 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 dedupCourse 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 valueCourse 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 localChapter 4's own end_scope — a direct revision, the same way Chapter 6 revised local addressing to be frame-relative

Hands-On Exercises

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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_upvalue walks outward through enclosing compilers; is_local distinguishes "capture a local directly" from "forward an upvalue an enclosing function already has"
  • OP_CLOSURE: builds the real runtime Closure, resolving each descriptor via capture_upvalue (dedup'd by slot) or by forwarding frame.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 Upvalue object, 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-known var-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
Chapter 8 of 10

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

Python already manages memory automatically — say so plainly before building anything
A real bytecode VM written in C (the design this course has followed throughout) has no garbage collector unless it builds one — a 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.

def alloc_closure(self, function, upvalues): c = Closure(function, upvalues) self.heap.append(c) # NEW -- every closure gets tracked return c def capture_upvalue(self, abs_slot): if abs_slot in self.open_upvalues: return self.open_upvalues[abs_slot] upv = Upvalue(self.stack, abs_slot) self.heap.append(upv) # NEW -- every upvalue gets tracked self.open_upvalues[abs_slot] = upv return upv

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).

def mark_roots(self): for value in self.stack: if isinstance(value, (Closure, Upvalue)): self.mark_object(value) for value in self.globals.values(): if isinstance(value, (Closure, Upvalue)): self.mark_object(value) for frame in self.frames: self.mark_object(frame.closure) for upv in self.open_upvalues.values(): self.mark_object(upv)

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.

def mark_object(self, obj): if obj is None or obj.marked: return # already visited -- stop, don't loop forever obj.marked = True if isinstance(obj, Closure): for upv in obj.upvalues: self.mark_object(upv) # a closure keeps its own upvalues alive elif isinstance(obj, Upvalue): if obj.closed and isinstance(obj.value, Closure): self.mark_object(obj.value) # a closed upvalue might be HOLDING a closure # if OPEN, its value lives on the stack -- already a root, nothing more to do

Sweep: Discarding What Isn't Marked

def collect_garbage(self): self.mark_roots() survivors = [obj for obj in self.heap if obj.marked] collected = len(self.heap) - len(survivors) for obj in self.heap: obj.marked = False # reset every mark for the NEXT cycle self.heap = survivors return collected

Verified: a Real Collection Cycle

fun makeAdder(x) { fun adder(y) { return x + y; } return adder; } var keep = makeAdder(10); // kept -- reachable via the global 'keep' makeAdder(20); // discarded immediately -- garbage makeAdder(30); // discarded immediately -- garbage print keep(5); // 15
Verified directly — exactly 5 of 8 allocated objects are correctly identified and removed
Before 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 survivemakeAdder'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.
Verified directly — the surviving closure remains fully functional after collection
Calling 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.
Verified directly — a program with nothing genuinely unreachable collects almost nothing
Storing all three 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.

Verified directly — the VM's own heap list holds a real, counted Python reference to every object it tracks
Inspecting 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.
The append-only bookkeeping list is the real leak — this chapter's own collector is what fixes it
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 findingWhat it connects to
Roots include every active frame's own closure, and every open upvalueChapter 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 transitivelyChapter 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 bookkeepingA 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

Chapter 8 Quick Reference

  • Heap: every Closure and Upvalue ever allocated, tracked in self.heap from 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.heap itself 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
Chapter 9 of 10

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

class WispClass: def __init__(self, name, superclass=None): self.name = name self.superclass = superclass self.methods = {} # name -> Closure def find_method(self, name): if name in self.methods: return self.methods[name] if self.superclass is not None: return self.superclass.find_method(name) return None class WispInstance: def __init__(self, klass): self.klass = klass; self.fields = {} class BoundMethod: def __init__(self, receiver, method): self.receiver = receiver; self.method = method # method: a Closure

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:

def compile_function(self, node, is_initializer, is_method): func_compiler = Compiler(enclosing=self, enclosing_scope_depth=1) func_compiler.is_initializer = is_initializer if is_method: func_compiler.declare_local("this") # ALWAYS slot 0 for a method for param in node.params: func_compiler.declare_local(param) # slots 1, 2, ... # ... compile the body exactly like any other function ...

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.

The actual cause — slot 0 pointed one position too late
For an ordinary function call, the callee's own value is discarded — it never becomes a local, so slot 0 correctly means "the first real argument," one position after the callee. For a method or constructor call, the receiver replaces the callee's own slot and becomes slot 0 itself — one position earlier than Chapter 6's formula assumed. Reusing that formula unchanged pointed 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.

Verified directly — the fix resolved it, matching Course 1's own Greeter example exactly
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.

elif instr == OP_METHOD: name = constants[code[frame.ip]]; frame.ip += 1 method_closure = self.stack.pop() self.stack[-1].methods[name] = method_closure # the class is still right there

Property Access: Get and Set

elif instr == OP_GET_PROPERTY: name = constants[code[frame.ip]]; frame.ip += 1 instance = self.stack.pop() if name in instance.fields: self.stack.append(instance.fields[name]) # a field wins first else: method = instance.klass.find_method(name) if method is None: raise RuntimeError(f"Undefined property '{name}'.") self.stack.append(BoundMethod(instance, method)) # a lightweight PAIR, nothing more

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

Verified directly — matches Course 1's own correct result, never its own found bug
Course 1's own 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.
Verified directly — single inheritance, override, and fallback all resolve correctly
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.

def visit_return_stmt(self, node): if self.is_initializer: self.chunk.write(OP_GET_LOCAL, node.line) self.chunk.write(0, node.line) # slot 0 -- 'this' -- ALWAYS, regardless of node.value elif node.value is not None: self.visit(node.value) else: # push nil self.chunk.write(OP_RETURN, node.line)
Verified directly — an explicit return inside init is compiled away entirely, never merely overridden
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 compiledself.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?

Verified directly — the lightweight pairing is a real, measured 2.27x cheaper per access
20,000 isolated allocations, median of 7 runs: a 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 findingWhat it connects to
this resolves through ordinary resolve_local/resolve_upvalueChapters 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 + 1Chapter 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 bugCourse 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 opcodeChapter 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

Chapter 9 Quick Reference

  • New types: WispClass (callable, constructs instances), WispInstance (a fields dict), 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 own base = origin + 1 — reusing the old formula silently pointed this at 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 return this at compile time — an explicit conflicting return value is never even compiled, not just overridden at runtime
  • Verified — fair, isolated comparison: a lightweight BoundMethod pair is 2.27× cheaper per access than allocating a fresh closure the way a tree-walking interpreter's own bind() does
  • Next chapter: Capstone — benchmarking this finished VM against Course 1's own tree-walking interpreter, on the same program
Chapter 10 of 10

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.

Verified directly — identical output from three independently-built implementations of the same program
The bytecode VM's own output: 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.

Verified directly — the bytecode VM is measurably SLOWER, not faster, for this workload
2,000 tasks created and filtered through a closure-based check, median of 9 runs: tree-walking interpreter — 0.0372s. Bytecode VM (compile + run) — 0.0758s. A ratio of 0.490× — the bytecode VM took roughly twice as long as the tree-walker for the identical program, identical result.
This isn't a fluke of one run, or of compile time
The same ratio holds at 1,000 tasks (0.458×) and 4,000 tasks (0.507×) — stable across a 4× range, not narrowing or reversing as the program grows. Timing the bytecode VM's own execution alone, with compilation excluded entirely, gives 0.0746s — statistically indistinguishable from the 0.0758s compile-plus-run figure. Compiling isn't the cost. The VM's own per-opcode dispatch loop, running during actual execution, is.

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.

Verified directly — even a plain loop with no classes at all still favors the tree-walker
A bare loop with no classes, no closures, no method calls — just 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 shapeResultWhy
One deeply-nested expression (Chapter 1, 201+ nodes)Bytecode ~1.7× fasterTree-walking recursion depth compounds with nesting depth; the VM's flat instruction loop doesn't
A shallow loop body, repeated 4,000 timesTree-walk ~2.1× fasterNo 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× fasterEvery 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 componentChapter
Reusing Course 1's own lexer, parser, and AST unchangedChapter 1 — "nothing about the front end needs to be rewritten"
Chunks, opcodes, the fetch-decode-execute loopChapter 2
The single-pass compiler walking the same ASTChapter 3
TaskQueue's own fields, this.head/this.countChapter 4 (globals/locals) — extended by Chapter 9 for instance fields
The while loop walking the linked list; >=/!= closed hereChapter 5
countMatching and makeUrgencyChecker's own recursive-safe callsChapter 6
tally's closure over count and checkerChapter 7
Every Closure/Upvalue this program allocatesChapter 8 (tracked; nothing here was ever actually swept, since the whole program stays reachable throughout)
Task/UrgentTask/TaskNode/TaskQueue, inheritance, this, bound methodsChapter 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 BoundMethod pair, 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