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.