Step 5 — Control Flow (if / while)

All conditional execution in MiniLang's bytecode is achieved with three opcodes:

  • JumpIfFalse off — if !truthy(peek()), advance ip by off. Value remains on the stack.
  • Jump off — unconditional forward branch.
  • Loop off — unconditional backward branch (subtracts off from ip).

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> breakSites per active loop. break does emitJump(Op::Jump) and pushes the placeholder offset. On loop exit, patch all of them to point to the post-loop L_EXIT.
  • continue jumps to L_START (a backward Loop, computed inline).

cp-15 adds these.

Self-Check

  • Why does if produce two Pop opcodes but a single <cond>?
  • What happens if you remove the post-loop Pop from while's lowering?
  • How does JumpIfFalse differ from a hypothetical BranchIfFalse that pops the value? Which would you prefer for short-circuit logic in step 6?