Step 6 — Short-circuit and a preview of phi

a && b and a || b are not arithmetic operators — they have short-circuit semantics. b is only evaluated when the result is not yet determined by a. That's a control-flow property; expressing it as a regular binary op would either evaluate both eagerly (wrong) or require a magic "lazy" flag (gross).

The right move is to lower logical operators into the same shape as a hand-written if:

result = a;
if (!result_truthy)  result = b;          // for &&
if ( result_truthy)  result = b;          // for ||
use result

…which, expressed in TAC blocks, looks like this:

        ┌── cjmp slot ──→ and.eval ──jmp──┐
pre ────┤   (true case)                   ▼
        └─── (false case) ──────────► and.join
                                       (slot = a || b)

The implementation

void Builder::visit(LogicalExpr& e) {
    Operand lhs  = eval(*e.lhs);
    Operand slot = freshTemp();
    emit({Op::Move, slot, {lhs}, "", -1, -1, e.line});

    auto& evalBlock = fn().newBlock(e.op == TokenKind::AmpAmp ? "and.eval" : "or.eval");
    auto& joinBlock = fn().newBlock(e.op == TokenKind::AmpAmp ? "and.join" : "or.join");
    int evalId = evalBlock.id, joinId = joinBlock.id;

    if (e.op == TokenKind::AmpAmp) emitCondJump(slot, evalId, joinId, e.line);
    else                            emitCondJump(slot, joinId, evalId, e.line);

    setBlock(evalId);
    Operand rhs = eval(*e.rhs);
    emit({Op::Move, slot, {rhs}, "", -1, -1, e.line});
    emitJump(joinId, e.line);

    setBlock(joinId);
    result_ = slot;
}

The crucial detail: both predecessors of the join block write to the same temp slot. That's why we treat the temp as a write-many named slot in cp-08 — temps are not yet SSA values.

Preview: this is where phi nodes come in

In SSA form, every value must have exactly one definition. Our slot violates that — it's defined twice, once on each path into the join. The classical fix is the phi node, a pseudo-instruction at the top of a block that selects a value based on which predecessor arrived:

join:
    slot = phi [lhs_value, pre], [rhs_value, and.eval]

cp-09's SSA construction pass will scan for our slot pattern and emit exactly such a phi. In fact, every multi-write temp our cp-08 builder produces — including locals — becomes either a phi or gets folded into a single dominating definition.

For now we model the join with explicit moves because it lets cp-08 stay strictly imperative. No worklist algorithms, no dominator trees, no iterated dominance frontier. Those are cp-09's burden, and seeing the pre-SSA form here is what makes their effect legible later.

Why not just do SSA construction now?

We considered it. But: SSA construction is genuinely intricate (Cytron's algorithm depends on dominator trees, which depend on the CFG, which depends on lowering being done…), and lumping the two concepts into one lab obscured both. Splitting them by step gives a cleaner narrative: cp-08 builds the grammar; cp-09 imposes the invariant.