Control Flow & Jumps in Bytecode
Writing a Compiler/Interpreter: Advanced
Chapter 5 · Control Flow & Jumps in Bytecode
Every chunk compiled so far has run start to finish, one instruction after the next, with no way to skip anything or repeat anything. Real programs branch and loop. This chapter breaks the "compile children, then emit this node's own instruction" rule Chapter 3 established — on purpose — because if and while need something no earlier chapter needed: an instruction whose own operand can't be known until code that comes after it has already been compiled.
First, Something Worth Branching On
Every value compiled through Chapter 4 has been a plain number — always truthy under Wisp's own rule (only nil and false are falsy). A condition that's always true isn't a condition. Three small comparison opcodes fix that, plus is_truthy, reused directly from Course 1, Chapter 4's own rule:
Nothing new needed for boolean or nil literals — Literal(True) and Literal(None) already flow through Chapter 3's own visit_literal for free, since it treats a constant's Python value generically. Only the comparisons that actually produce a boolean needed new opcodes.
The Problem: a Jump Target That Isn't Known Yet
Compiling if (cond) { thenBranch } means emitting something like "if cond is false, skip past thenBranch." That skip instruction has to say how far to jump — but at the point the compiler is ready to emit it, thenBranch hasn't been compiled yet. Its size in bytes literally doesn't exist until after it's compiled. There's no way to know the jump distance in advance.
Backpatching: Emit a Placeholder, Fix It Later
The fix is to emit the jump instruction with a fake, obviously-wrong operand, remember exactly where those bytes live in the chunk, compile whatever comes next, and then — now that its real size is known — go back and overwrite the placeholder with the correct value.
OP_CONSTANT's operand) caps a jump distance at 255 — comfortably enough for one arithmetic expression, nowhere near enough for a real loop body or function. This chapter's jump instructions read two operand bytes as one 16-bit distance from the start, avoiding a ceiling that program size hits far more easily than constant count or local count ever would. (Verified below — this isn't a precaution taken "just in case.")
Compiling if/else
if (1 < 2) { print 100; } else { print 200; } runs to ['100']. Flip the condition to 5 < 2: ['200']. Drop the else entirely with a false condition — if (5 < 2) { print 100; } print 999; — runs to ['999'] only; the then-branch's own OP_CONSTANT/OP_PRINT pair is skipped over completely.
Disassembling the no-else case for if (1 < 2) { print 100; } shows the backpatch landed exactly where it should:
OP_JUMP_IF_FALSE was written at offset 5 with a placeholder, back when nothing after it existed yet. Only once OP_PRINT at offset 10 had actually been compiled — making the chunk exactly 11 bytes at that point — did patch_jump compute the real distance, 3, and overwrite the two placeholder bytes with it.
Compiling while: Jumping Backward
A loop needs the opposite direction too — after the body runs, control has to go back to re-check the condition. OP_LOOP handles that: same 2-byte offset, but the VM subtracts instead of adds.
loop_start needs no backpatching at all — unlike a forward jump's target, it's already known the moment the loop begins, since nothing about where the condition starts depends on anything compiled later.
var i = 0; while (i < 5) { print i; i = i + 1; } runs to ['0', '1', '2', '3', '4'].
Disassembling a smaller loop (var i = 0; while (i < 2) { i = i + 1; }) shows both directions in one program:
OP_JUMP_IF_FALSE at offset 9 jumps forward, past the whole loop body, to offset 23 once i < 2 goes false. OP_LOOP at offset 20 jumps backward to offset 4 — not offset 0, deliberately: offset 4 is where the condition check begins (OP_GET_GLOBAL i), skipping back over the one-time var i = 0; initialization, which only needs to run once.
Nested Control Flow: Backpatches Don't Interfere With Each Other
var i = 1; while (i < 4) { if (i < 2) { print 100; } else { print 200; } i = i + 1; } runs to ['100', '200', '200'] — correct for i = 1, 2, 3. Each call to emit_jump/patch_jump only ever touches the two placeholder bytes its own jump_pos points at; nothing about compiling the inner if's own two jumps disturbs the outer while's own loop_start or its still-unpatched exit jump.
Proving the 2-Byte Offset Is Necessary, Not Just Cautious
while loop body padded with 150 filler statements compiles to 477 total code bytes and still runs correctly. The same program, compiled with a deliberately naive one-byte jump encoding instead, fails immediately with ValueError: byte must be in range(0, 256) — the exact same failure signature Chapters 2-4 found for the constant pool and the local-slot ceiling, this time triggered by something as ordinary as "a loop body with more than a couple dozen statements in it."
For Loops Need Zero New Compiler Code
Course 1, Chapter 6 made a specific, verified choice: for is desugared at parse time into existing node types — a var declaration, a while loop, and a block — rather than getting its own ForStmt class. That decision pays off here for free.
for (var i = 0; i < 3; i = i + 1) { print i; }, built as its own desugared form — { var i = 0; while (i < 3) { { print i; } i = i + 1; } } — compiles and runs to ['0', '1', '2'], using only visit_var_stmt, visit_while_stmt, and visit_block_stmt, all already written. No visit_for_stmt exists anywhere in this compiler, and none was needed.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Backpatching: emit a placeholder, fix it once the real size is known | The first real break from Chapter 3's own "compile children, then emit this node" rule — control flow genuinely needs a different compilation strategy than pure expressions, exactly as Chapter 3's own closing note predicted |
| Jump offsets use 2 bytes; constants and locals use 1 | Chapters 2 and 4's own 256 ceilings — this chapter deliberately avoids the same mistake for a resource (program size) far more likely to exceed it in ordinary code |
A desugared for loop needed zero new compiler code | Course 1, Chapter 6's own "for fully desugared, zero new interpreter methods" finding — the exact same payoff shows up a second time, one course later, in a completely different execution strategy |
OP_LOOP jumps back to the condition check, not the very start of the loop | Chapter 6's own call frames — a function's own initialization work (parameter binding) will need the same "run once, don't re-loop over it" discipline this chapter's loop_start placement already established |
Hands-On Exercises
Compile if (1 < 2) { print 100; } (no else) using this chapter's own Compiler, then disassemble the resulting chunk. Compute, by hand, the exact value patch_jump writes into OP_JUMP_IF_FALSE's two operand bytes, and verify it against the disassembly's own reported jump distance and target offset.
Build the desugared AST for for (var i = 0; i < 3; i = i + 1) { print i; } — a var declaration, a while loop, and a block, per Course 1, Chapter 6's own desugaring rule — and compile it. Confirm which existing visit_* methods handle every part of it, and identify by name every AST node type involved.
Using this chapter's own comparison of a one-byte versus two-byte jump encoding, find the exact filler-statement count where a one-byte encoding starts failing on a padded while loop body, via binary search (matching the technique this course has used before for similar ceilings). Confirm your answer against a direct compile attempt at that exact count and the count just below it.
Chapter 5 Quick Reference
- New opcodes:
OP_EQUAL/OP_GREATER/OP_LESS(comparisons),OP_JUMP/OP_JUMP_IF_FALSE(forward),OP_LOOP(backward) — all jumps use a 2-byte offset - Backpatching:
emit_jumpwrites a placeholder and returns its position;patch_jumpoverwrites it once the real distance is known - Verified: if/else, no-else, and while all compile and run correctly, confirmed against hand-computed disassembly
- Verified: nested control flow (an
ifinside awhile) compiles correctly — independent backpatches never interfere with each other - Verified: a one-byte jump offset fails on an ordinary-sized loop body (crossover at 79/80 filler statements) — the 2-byte design is necessary, not cautious
- Verified: a desugared
forloop compiles and runs correctly with zero new compiler code, mirroring Course 1 Chapter 6's own identical finding - Next chapter: Functions & Call Frames — resolving Chapter 1's own 163-level recursion ceiling with an explicit frame stack