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