Step 6 — Short-Circuit Logic

&& and || are not arithmetic operators — they have to avoid evaluating the right-hand side when the left settles the answer. That's "short-circuiting", and it's expressed entirely with jumps.

a && b

  <a>
  JumpIfFalse  L_END
  Pop                  ; drop the (truthy) <a>
  <b>
L_END:                 ; either <a> (if it was falsy) or <b> sits on the stack

If a is falsy, we skip the Pop <b> block and the falsy value of a remains as the result — exactly what a && b should return when a is falsy. If a is truthy, we pop it and evaluate b, so b is the result.

void Compiler::visit(LogicalExpr& e) {
    e.lhs->accept(*this);
    if (e.op == TokenKind::AmpAmp) {
        size_t endJump = emitJump(Op::JumpIfFalse, e.line);
        emit(Op::Pop, e.line);
        e.rhs->accept(*this);
        patchJump(endJump, e.line);
    } else if (e.op == TokenKind::PipePipe) {
        // see below
    }
}

a || b

  <a>
  JumpIfFalse  L_RHS
  Jump         L_END   ; <a> was truthy; keep it as result
L_RHS:
  Pop                  ; drop the (falsy) <a>
  <b>
L_END:

If a is truthy, we jump straight to L_END leaving a on the stack. If a is falsy, we pop it and evaluate b so b becomes the result.

size_t elseJump = emitJump(Op::JumpIfFalse, e.line);
size_t endJump  = emitJump(Op::Jump, e.line);
patchJump(elseJump, e.line);
emit(Op::Pop, e.line);
e.rhs->accept(*this);
patchJump(endJump, e.line);

Why JumpIfFalse Doesn't Pop

If JumpIfFalse popped its condition, encoding &&/|| would need a Dup opcode (push another copy first) to preserve the value as the result. By keeping JumpIfFalse non-popping and emitting an explicit Pop on the "consume-the-condition" branch only, we save the Dup and one byte per logical operator.

Lox's compiler in Crafting Interpreters makes the same call.

Truthiness Reminder

Because JumpIfFalse uses Value::isTruthy, you get JavaScript-like coalescing semantics:

print 0 || 5;       // 0  (since 0 is truthy in MiniLang — Lua semantics)
print nil || "hi";  // "hi"
print false || 42;  // 42
print 3 && 4;       // 4

To get C-style &&/|| that return 0 or 1 you'd add a final Op::Not Op::Not to coerce — the test of "is this booleanable" runs twice.

Verifying the Compilation

The unit tests assert the exact opcode sequence:

void test_short_circuit_and() {
    auto out = compileSource("print false && true;");
    CHECK(opsMatch(out.chunk, {
        Op::False, Op::JumpIfFalse, Op::Pop, Op::True,
        Op::Print, Op::Return
    }));
}

This is exactly the lowering we sketched.

Self-Check

  • Why don't we need a Dup opcode given how JumpIfFalse is defined?
  • Walk through the compiled bytecode for a && b || c. (Hint: || has the lowest precedence; && binds tighter.)
  • How would you implement ?? (null-coalescing, "use rhs if lhs is nil else lhs")?