Step 5 — Control Flow (if / while)
All conditional execution in MiniLang's bytecode is achieved with three opcodes:
JumpIfFalse off— if!truthy(peek()), advanceipbyoff. Value remains on the stack.Jump off— unconditional forward branch.Loop off— unconditional backward branch (subtractsofffromip).
Pop is the other star of the show — without it, the runtime stack would slowly fill with leftover conditions.
Lowering if (cond) then [else other]
<cond>
JumpIfFalse L_ELSE
Pop ; drop condition on the then-path
<then>
Jump L_END
L_ELSE:
Pop ; drop condition on the else-path
<other> ; (omitted if no else, but the Pop still emits)
L_END:
Two pops, one per branch. JumpIfFalse leaves the value on the stack (it has to — if we popped first we couldn't both consult the value AND keep the stack discipline aligned across both branches). Each branch is then responsible for popping it once it's chosen.
If the else branch is missing, the structure simplifies but the second Pop still has to run — otherwise the runtime stack drifts upward by one each time we hit a "no-else" if.
In code (compiler.cpp):
void Compiler::visit(IfStmt& s) {
s.cond->accept(*this);
size_t thenJump = emitJump(Op::JumpIfFalse, s.line);
emit(Op::Pop, s.line);
s.thenBranch->accept(*this);
size_t elseJump = emitJump(Op::Jump, s.line);
patchJump(thenJump, s.line);
emit(Op::Pop, s.line);
if (s.elseBranch) s.elseBranch->accept(*this);
patchJump(elseJump, s.line);
}
Lowering while (cond) body
L_START:
<cond>
JumpIfFalse L_EXIT
Pop ; drop condition on the body-path
<body>
Loop L_START
L_EXIT:
Pop ; drop condition on the exit-path
void Compiler::visit(WhileStmt& s) {
size_t loopStart = chunk_->code.size();
s.cond->accept(*this);
size_t exitJump = emitJump(Op::JumpIfFalse, s.line);
emit(Op::Pop, s.line);
s.body->accept(*this);
emitLoop(loopStart, s.line);
patchJump(exitJump, s.line);
emit(Op::Pop, s.line);
}
Note the same pop-on-both-branches dance. The Loop instruction takes an unsigned 16-bit operand and the VM does ip -= off. Because we know loopStart upfront, no back-patching is needed.
Truthiness
JumpIfFalse consults Value::isTruthy():
bool Value::isTruthy() const {
switch (kind) {
case K::Nil: return false;
case K::Bool: return b;
default: return true; // numbers (including 0!), strings, fns
}
}
This matches Lua and Ruby: only nil and false are falsy. 0 is truthy, "" is truthy. The "JavaScript / Python" alternative — empty containers and zero are falsy — is a different design choice; both are coherent.
Nested Control Flow
Each if/while is independent — its own jumps, its own pops. Nesting Just Works because every visit method maintains the "expression leaves exactly one value on the stack" / "statement is stack-neutral" invariants.
For example, if (a) { while (b) { ... } } compiles to a while lowering nested inside the then-branch of the if. The if's Pop (then-path) drops a; the while's pops drop b. No interference.
break and continue?
Not yet — MiniLang has no break/continue keywords. Adding them is a fun exercise:
- Compile-time: keep a stack of
vector<size_t> breakSitesper active loop.breakdoesemitJump(Op::Jump)and pushes the placeholder offset. On loop exit, patch all of them to point to the post-loopL_EXIT. continuejumps toL_START(a backwardLoop, computed inline).
cp-15 adds these.
Self-Check
- Why does
ifproduce twoPopopcodes but a single<cond>? - What happens if you remove the post-loop
Popfromwhile's lowering? - How does
JumpIfFalsediffer from a hypotheticalBranchIfFalsethat pops the value? Which would you prefer for short-circuit logic in step 6?