11.1 Lowering AST to IR
An abstract syntax tree is designed for understanding source code. Intermediate representation (IR) is designed for executing, analyzing, and transforming it. Lowering is the semantics-preserving bridge between them: it replaces language-level constructs with smaller operations whose behavior is explicit.
Consider score += bonus * 2. The AST can keep += as one meaningful node. A three-address IR must expose the read of score, multiplication, addition, and final write. If the language promises checked overflow or precise source locations, lowering must expose those facts too.
Lowering normally begins after name resolution and type checking. That ordering matters. The lowerer should receive an AST where score already refers to a particular declaration, bonus * 2 already has a known type, and any required implicit conversions have been recorded. It should not have to guess whether + means integer addition, floating-point addition, or string concatenation. Its job is to translate an established semantic decision into valid IR.
A useful way to think about the boundary is:
checked AST + symbol/type facts + language rules
↓ lowering
typed instructions + basic blocks + source metadataThe output is more verbose because hidden work becomes explicit. That verbosity is a feature: later passes can inspect individual loads, checks, calls, and branches instead of trying to rediscover them inside a high-level node.
Lower structure, preserve behavior
A lowering rule is more than a tree rewrite. It is a contract:
- evaluate each source subexpression the required number of times;
- preserve the language's evaluation order and observable side effects;
- preserve exceptional behavior such as bounds and overflow checks;
- retain enough source metadata for diagnostics and debugging;
- produce valid IR types, blocks, and terminators.
For example, lowering items[next()] must not call next() twice just because both the bounds check and address calculation need the index. The correct pattern computes the index once and reuses its temporary.
%i = call @next()
%len = array.len @items
guard.in_bounds %i, %len
%addr = element_addr @items, %i
%value = load %addrNotice the dependency chain. %i is defined once and used by both guard.in_bounds and element_addr; the guard must execute before the load. If next() throws, no bounds check or load occurs. If the guard fails, no memory is read. These ordering facts are part of the source program's behavior.
Compound assignment adds another subtlety because its left side is both a location and a value. Lowering items[next()] += read() should compute the array element's address once, load the old value, call read(), add, and store back:
%i = call @next()
%addr = checked_element_addr @items, %i
%old = load %addr
%rhs = call @read()
%new = add %old, %rhs
store %new, %addrRe-running next() for the final store could update a different element. Re-loading the element after read() could also change semantics if read() can mutate items. A correct recipe therefore states exactly when the address and old value are captured.
A practical lowering architecture
A compiler usually gives each AST expression a routine such as lowerExpr(node) -> Value, while statements use lowerStmt(node). The lowering context owns the current basic block, fresh temporary names, symbol-to-storage mappings, and source-location metadata. This keeps mechanical IR construction separate from language policy.
The IR builder should enforce local invariants as instructions are created. For example, an integer add must receive equally sized integer operands, a load must receive an address of the expected element type, and a branch target must belong to the current function. Catching these mistakes at emission time gives a useful error near the faulty lowering rule instead of allowing malformed IR to fail much later.
The safest implementation strategy is local and testable: define one semantic contract per AST node, emit IR through a builder that rejects malformed operations, and compare source execution with IR execution on small programs. Lowering is correct when both have the same observable behavior—not when the emitted text merely looks plausible.
Tests should cover more than ordinary values. For every lowering rule, include zero or boundary values, a subexpression with a visible side effect, and a subexpression that throws or traps. Also run an IR verifier after lowering. Differential execution checks dynamic meaning, while verification checks structural properties such as valid types, existing branch targets, and one terminator per reachable block. Together they make the AST-to-IR boundary trustworthy.