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 LoadGlobal to 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, 2Operand::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.