Skip to content

Step 1 of 3 · Reading · ~3 min

The Compiler — AST to Bytecode

Compiling to Bytecode

A Compiler With No AST

Tree-walking interpreters build a full AST and then walk it at runtime. A bytecode compiler can skip the intermediate tree entirely: parse and emit in the same pass. As the parser recognizes each expression, it directly writes opcodes into the current Chunk. No Expr node classes, no second traversal — the recursive-descent parser is the code generator.

This only works cleanly for expressions because expressions have a strict precedence hierarchy, and that's exactly what a Pratt parser encodes.

Pratt Parsing in One Picture

Every token that can start or continue an expression gets two roles:

  • a prefix handler — how to parse it when it appears at the start of an expression (-5, (1+2), a number literal, a string literal)
  • an infix handler — how to parse it when it appears after another expression (a + b, a < b), paired with a precedence level
python

The loop is the whole trick: as long as the next operator binds at least as tightly as what the caller asked for, keep consuming it as part of the current expression. 1 + 2 * 3 parses correctly because * has higher precedence than +, so after emitting 1, the + handler recurses into parse_precedence(PREC_FACTOR) for its right operand, which slurps up 2 * 3 before returning control to the outer +.

Literals and Grouping

Numbers, strings, true/false/nil are the simplest case: look at the previous token's lexeme, wrap it as a Value, add it to the chunk's constant pool, and emit OP_CONSTANT <index> (or a dedicated OP_NIL / OP_TRUE / OP_FALSE — no constant slot wasted on a value the VM can synthesize with zero operands).

python

( expr ) is a prefix rule too — it just recurses:

python

Note that grouping emits no opcode of its own — parentheses only affect parsing order, not runtime behavior.

Unary and Binary Operators

unary() parses the operator, recursively parses its operand at PREC_UNARY, then emits the opcode after the operand — bytecode is stack-based, so operands must already be on the stack before the operator that consumes them:

python

binary() is the infix handler: it already has the left operand on the stack (emitted before we got here), so it parses the right operand at one precedence level higher than its own (for left-associativity), then emits the operator:

python

What to build

Wire a precedence table (token type → prefix fn, infix fn, precedence), then implement number, string, literal (true/false/nil), grouping, unary, and binary on top of it. Feed COMPILE "1 + 2 * (3 - 1)" through and confirm the emitted bytecode, when run by your VM from the previous chapter, produces 5. Watch operator precedence edge cases like -2 * 3 (unary binds tighter than *) and right-side unary in binary expressions like 1 - -1.

Up nextCompiling Statements & VariablesCompiling to Bytecode

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…