156. Control flow in Rust
A forward jump is written before anyone knows where it goes, so the compiler leaves two bytes blank and comes back for them. In Rust that hole is a usize, and coming back for it has one rule: report the error before you take hold of the code.
- Map drip-rs’s emit_jump, patch_jump and emit_loop to the TypeScript versions, and explain each difference.
- Explain why patch_jump checks for errors before it borrows the chunk’s code.
- Trace the bytes of a compiled while loop, with both jump offsets.
- Write emit_jump and patch_jump for a small compiler, with tests.
Two holes in a while loop
Here is let i = 0; while (i < 3) { print i; i = i + 1; }, from
drip-rs’s disassembler (the TypeScript VM prints the same):
0004 2 OP_GET_GLOBAL 2 'i'
0006 | OP_CONSTANT 3 '3'
0008 | OP_LESS
0009 | OP_JUMP_IF_FALSE 9 -> 27
0012 | OP_POP
0013 3 OP_GET_GLOBAL 4 'i'
... the body ...
0024 5 OP_LOOP 24 -> 4
0027 | OP_POP
When the compiler writes the JUMP_IF_FALSE at offset 9, the body
isn’t compiled yet, so nobody knows that the exit is at 27. Chapter 55’s
answer was backpatching: write a placeholder, remember where it is, and
fill it in later. drip-rs does exactly the same. What changes is the
types, and one ordering rule.
Writing a letter that says “see page ___” before you know how long the middle will be. You leave the blank, put a sticky note on that page of your own draft, and fill the number in when you get there. The sticky note is just a page number, not your hand holding the page down.
Leaving the hole
From src/compiler.rs, as are the next listings (adapted to fit):
fn emit_jump(&mut self, op: OpCode)
-> usize {
self.emit_op(op);
self.emit(0xff);
self.emit(0xff);
self.chunk().code.len() - 2
}
It returns where the two offset bytes are. TypeScript returned a
number; drip-rs returns a usize, the type of a Vec index, so it
can never be negative. The caller keeps it in a local:
let exit_jump = self.emit_jump(OpCode::JumpIfFalse);. That local is a
plain number. It borrows nothing, so the compiler is free to compile the
whole body before coming back. Holding a &mut u8 into the code
instead would lock the chunk for the entire body.
Filling it in
The TypeScript version, trimmed:
const jump =
chunk().code.length - offset - 2;
if (jump > 0xffff) error('Too much...');
chunk().code[offset] = (jump >> 8) & 0xff;
chunk().code[offset + 1] = jump & 0xff;
And drip-rs’s, adapted:
fn patch_jump(&mut self, offset: usize) {
let len = self.chunk().code.len();
let jump = len - offset - 2;
if jump > u16::MAX as usize {
self.error("Too much code to \
jump over.");
}
let code = &mut self.chunk().code;
code[offset] = (jump >> 8) as u8;
code[offset + 1] = jump as u8;
}
The - 2 is the same: a jump is measured from the end of its own
operand. as u8 keeps the low 8 bits, which is what TypeScript’s
& 0xff did[1]. And code is borrowed
once for both writes, where TypeScript called chunk() twice.
The order of the middle lines matters. Take code first and check
afterwards, and rustc 1.97 refuses (trimmed):
error[E0499]: cannot borrow `*self` as
mutable more than once at a time
7 | let code = &mut self.chunk().code;
| ---- first mutable
| borrow
...
10 | self.error("Too much code...");
| ^^^^ second mutable borrow
11 | }
12 | code[offset] = (jump >> 8) as u8;
| ---- first borrow later used here
code reaches into self through chunk(), and self.error needs
self too, to set had_error and panic_mode and push a message. So the error comes
first, while nothing is borrowed. It is chapter 152’s read-first habit
again: finish everything that needs all of self before borrowing a
piece of it.
Backpatching a jump in Rust: emit_jump writes the opcode and two
placeholder bytes, and returns the operand’s position as a usize. The
caller keeps that number while it compiles the code to be jumped over.
patch_jump(offset) then computes the distance from the end of the
operand to the end of the code, reports an error if it doesn’t fit in
16 bits, and only then borrows the code to write the two bytes, high
byte first.
Loops and the rest
A loop’s jump goes backwards, to a place already known, so it needs no patching:
fn emit_loop(&mut self, loop_start: usize) {
self.emit_op(OpCode::Loop);
let len = self.chunk().code.len();
let offset = len - loop_start + 2;
if offset > u16::MAX as usize {
self.error("Loop body too large.");
}
self.emit((offset >> 8) as u8);
self.emit(offset as u8);
}
The + 2 counts the two operand bytes not yet written. In the listing,
LOOP is at 24, so offset is 25 − 4 + 2 = 23, and the VM, with its
ip at 27 after the operand, lands on 27 − 23 = 4.
for_statement uses an Option where TypeScript used -1: a for
with no condition has nothing to exit on.
let mut exit_jump = None;
if !self.matches(TokenKind::Semicolon) {
// ... the condition ...
let j = self.emit_jump(
OpCode::JumpIfFalse);
exit_jump = Some(j);
}
// ... increment, body, emit_loop ...
if let Some(exit_jump) = exit_jump {
self.patch_jump(exit_jump);
self.emit_op(OpCode::Pop);
}
and and or are the same two calls in miniature. and is
emit_jump(JumpIfFalse), Pop, the right operand, then patch_jump.
The VM side is three short arms of the match:
OpCode::Jump => {
let offset = self.read_short();
self.ip += offset;
}
OpCode::Loop => {
let offset = self.read_short();
self.ip -= offset;
}
Two bytes limit a jump to 65,535 bytes, the same limit clox
has[3]. self.ip -= offset is usize
arithmetic. If a bad offset ever took ip below zero, a debug build
would panic on the overflow, and a release build, with overflow checks
off, would wrap around[2]. Neither can
read the wrong memory, because the next instruction is read with
self.chunk.code[self.ip], and indexing a Vec out of bounds panics.
The compiler never emits such an offset. But a bug in it would show up
as a clean panic, not as nonsense output.
Your turn
Write the two halves of a forward jump for this small compiler.
emit_jump(op)emitsopand two placeholder bytes0xff, 0xff, and returns the index of the first placeholder.patch_jump(at)makes that jump land on the next byte to be emitted. The distance counts from the end of the two-byte operand. Store it high byte first. If it doesn't fit in au16, reportTOO_FARwithself.error.
Remember the order: self.error needs all of self, so call it before you borrow self.code for writing.
const JUMP: u8 = 1;
const JUMP_IF_FALSE: u8 = 2;
const POP: u8 = 3;
const TOO_FAR: &str =
"Too much code to jump over.";
struct Compiler {
code: Vec<u8>,
errors: Vec<String>,
}
impl Compiler {
fn new() -> Self {
Compiler {
code: Vec::new(),
errors: Vec::new(),
}
}
fn emit(&mut self, byte: u8) {
self.code.push(byte);
}
fn error(&mut self, msg: &str) {
self.errors.push(msg.into());
}
// Emit `op`, then a 2-byte hole.
// Return where the hole is.
fn emit_jump(&mut self, op: u8)
-> usize {
todo!()
}
// Make the jump whose operand is at
// `at` land on the next byte.
fn patch_jump(&mut self, at: usize)
{
todo!()
}
}Write emit_loop(to): emit LOOP, then a two-byte offset, high byte first, such that the VM jumps back to to.
The VM reads the offset with its ip just past the operand and subtracts it (the tests' lands_at does the same). If the offset doesn't fit in a u16, report TOO_BIG.
const LOOP: u8 = 4;
const POP: u8 = 3;
const TOO_BIG: &str =
"Loop body too large.";
struct Compiler {
code: Vec<u8>,
errors: Vec<String>,
}
impl Compiler {
fn new() -> Self {
Compiler {
code: Vec::new(),
errors: Vec::new(),
}
}
fn emit(&mut self, byte: u8) {
self.code.push(byte);
}
fn error(&mut self, msg: &str) {
self.errors.push(msg.into());
}
// Emit LOOP and a 2-byte offset
// that takes the VM back to `to`.
fn emit_loop(&mut self, to: usize)
{
todo!()
}
}What you learned
emit_jumpwrites the opcode and0xff 0xff, and returns the operand’s position as ausize. Keeping a number instead of a reference leaves the chunk free while the jumped-over code compiles.patch_jumpcomputeslen - offset - 2, checks it fits in au16, and writes it high byte first withas u8.- Report before you borrow:
self.errorneeds all ofself, so it must come beforelet code = &mut self.chunk().code;. emit_loopadds 2 for its own operand. A missingforcondition isNone, not-1.
Check your understanding
0/6- 1.
emit_jumpreturns 10. What is at offsets 10 and 11 right after it returns? - 2.
The
JUMP_IF_FALSEoperand is at offset 10, and the code is 27 bytes long when it is patched. What distance is written? - 3.
Why does this
patch_jumpfail to compile?let code = &mut self.chunk().code; let jump = code.len() - offset - 2; if jump > u16::MAX as usize { self.error("Too much code..."); } code[offset] = (jump >> 8) as u8; - 4.
What does
(jump >> 8) as u8give forjump = 300? - 5.
Why doesn’t a
LOOPinstruction need backpatching? - 6.
In drip-rs, a
forloop has no condition. How doesfor_statementrecord that?
References
- [1] The Rust Reference contributors. Operator expressions: Numeric cast. The Rust Reference. doc.rust-lang.org/reference/expressions/operator-expr.html#numeric-cast (checked 2026-10-31)
- [2] The Rust Reference contributors. Operator expressions: Overflow. The Rust Reference. doc.rust-lang.org/reference/expressions/operator-expr.html#overflow (checked 2026-10-31)
- [3] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)