Step 4 — Lowering expressions
Expression lowering in ir_builder.cpp follows one rule: every visit
method writes the expression's result operand into result_, and
returns void. A helper eval(Expr&) runs accept and reads back
result_:
Operand eval(Expr& e) { e.accept(*this); return result_; }
This dance is a workaround for the AST's typed-visitor design (which
specialises only ExprVisitor<TypePtr> and ExprVisitor<void>), and
gives us a useful invariant: the IR Builder reuses the exact same
visitor base class as the bytecode compiler.
Constants and identifiers
void Builder::visit(LiteralExpr& e) { result_ = Operand::constant(e.value); }
void Builder::visit(IdentExpr& e) {
if (isLocal(e.name)) { result_ = Operand::named(e.name); return; }
Operand dst = freshTemp();
emit({Op::LoadGlobal, dst, {}, e.name, -1, -1, e.line});
result_ = dst;
}
- Literals are pure data — no instruction, just an operand.
- Locals become named operands; no instruction is emitted on a read. Reads happen for free at the use site, which is correct for an alloca-style memory model.
- Globals require an explicit
LoadGlobalto a fresh temp.
This asymmetry between locals and globals matters: in the SSA promotion
pass of cp-09 we will recognise alloca-like patterns over %name
operands and promote them. Globals will stay as memory because they
cross function boundaries.
Unary and binary operators
void Builder::visit(BinaryExpr& e) {
Operand a = eval(*e.lhs);
Operand b = eval(*e.rhs);
Operand dst = freshTemp();
emit({binOpFor(e.op), dst, {a, b}, "", -1, -1, e.line});
result_ = dst;
}
Subexpressions are recursively lowered first, producing fresh temps, and the parent's instruction binds those temps. This is the literal "flatten compound expressions into single ops" recipe — the whole point of TAC.
Constant folding could happen here (add 1, 2 → Operand::constant(3))
but we don't do it. Folding is a pass, not a lowering concern. cp-09
introduces a constant-folder pass that consumes the unfolded IR cp-08
emits — and being able to see the pre-fold IR makes the pass's effect
obvious in diff tests.
Calls
void Builder::visit(CallExpr& e) {
std::string calleeName;
Operand calleeOp;
if (auto* id = dynamic_cast<IdentExpr*>(e.callee.get())) {
calleeName = id->name; // direct call
calleeOp = Operand::named(id->name);
} else {
calleeOp = eval(*e.callee); // indirect (no closures yet though)
calleeName = "<indirect>";
}
std::vector<Operand> args { calleeOp };
for (auto& a : e.args) args.push_back(eval(*a));
Operand dst = freshTemp();
Instr ins{ Op::Call, dst, std::move(args), calleeName, -1, -1, e.line };
emit(std::move(ins));
result_ = dst;
}
The callee always lives at srcs[0], even for direct calls. Carrying
the name separately is redundant but useful: it makes the printed IR
read as t0 = call @add(3, 4) instead of t0 = call %add, 3, 4, and
it lets passes filter direct vs indirect calls without parsing operands.
Argument evaluation order is left-to-right, exactly matching the language semantics enforced by the parser.
Assignments
void Builder::visit(AssignExpr& e) {
Operand val = eval(*e.value);
if (isLocal(e.name)) {
emit({Op::Move, Operand::named(e.name), {val}, "", -1, -1, e.line});
} else {
emit({Op::StoreGlobal, Operand::none(), {val}, e.name, -1, -1, e.line});
}
result_ = val;
}
Note that assignment returns the assigned value (as an expression),
which is needed for let y = (x = 3);. Locals use Move; globals use
StoreGlobal. The destination of an assignment is the operand we
wrote, not the value we just produced — which means cp-09's mem2reg
can use Move %x, v as the SSA-construction's "definition of x" point.