Chapter 12: The compiler, part 2: functions and calls
This chapter:
- Compile each top-level function to its own independent
Chunk, stored as a constant, bound to its name exactly like a top-levelvar. - Compile
CALL/RETURN, and see recursion fall out for free from Chapter 11's own machinery. - Give Muspel's honestly-scoped closures a bytecode-world equivalent of Chapter 8's "nearly free" claim, and name exactly what's not free about it.
Where we left off: musc compile handles every construct through while, correctly, except functions and the calls that invoke them.
12.1 A function is a constant
A top-level func compiles almost exactly like a top-level var: build the value (here, a compiled function rather than a literal), push it, then bind it to a name with Define_Global.
case ^Stmt_Func:
proto := compile_function(node)
idx := chunk_add_const(c.chunk, Const_Value(proto))
chunk_write_op(c.chunk, .Constant, node.line)
chunk_write(c.chunk, idx, node.line)
name_idx := chunk_add_const(c.chunk, Const_Value(node.name))
chunk_write_op(c.chunk, .Define_Global, node.line)
chunk_write(c.chunk, name_idx, node.line)
Func_Proto is what compile_function builds: a name, an arity a call site can be checked against, and its own, entirely independent Chunk, joining Const_Value's union (Chapter 10 showed the field, unused until now):
Func_Proto :: struct {
name: string,
arity: int,
chunk: Chunk,
}
compile_function compiles the body with a fresh Compiler, scope_depth starting at 1 directly rather than reached by a call to begin_scope, and with no matching end_scope call once the body's done:
compile_function :: proc(decl: ^Stmt_Func) -> ^Func_Proto {
chunk := chunk_init()
c := compiler_init(&chunk)
c.scope_depth = 1
for param in decl.params {
declare_local(&c, param)
}
for stmt in decl.body.stmts {
compile_stmt(&c, stmt)
}
chunk_write_op(&chunk, .Nil, decl.line)
chunk_write_op(&chunk, .Return, decl.line)
// ...
}
That asymmetry (scope_depth = 1 with no closing end_scope) is deliberate, not an oversight, and it's the same asymmetry Chapter 7's resolver already committed to: a function's parameters and its body's own top-level statements share one scope, never two nested ones. Section 11.1 explained why the compiler can't reuse the resolver's slot numbers directly; this is the one place it does reuse the resolver's actual structural decision, because that decision (one shared scope, not two) isn't backend-specific at all, it's a fact about Muspel's grammar. And there's no end_scope at the end because there doesn't need to be: Return (section 12.2) discards the entire call frame at once, at the VM level (Chapter 13), so individually popping every local a function declared, one Pop at a time, the way an ordinary block's end_scope does, would just be wasted work immediately before the frame it belongs to disappears wholesale anyway.
Every function chunk ends with an implicit Nil; Return, appended after the body compiles, whether or not the source had an explicit return on every path, exactly Chapter 8's "implicit nil return" rule, now enforced by the compiler instead of the tree-walker.
Gotcha: top-level functions have to be hoisted, or forward references break at run time.
compile_program(Chapter 11) walks a file's top-level statements once, in order, compiling each as it goes, which is the obviously simple thing to do and is wrong for one specific case: a top-level function that calls another one declared later in the same file, if it's invoked before that later declaration's ownDefine_Globalhas actually run.examples/forward_ref.musis exactly this case:func a() { return b() } print(a()) func b() { return 42 }Compiled in strict file order,
print(a())executes beforefunc b's ownConstant/Define_Globalpair ever runs, so callingbfrom insideafails with an undefined global at the exact moment a real VM would run it, even though Chapter 7's resolver accepts this program without complaint (its own hoisting pass registers every top-level function's arity before resolving any body, precisely so forward references type-check) and Chapter 8's tree-walker runs it correctly (exec_programdefines every top-level function inglobalsbefore executing a single statement):./run.sh run examples/forward_ref.musprints42. A compiler that skipped hoisting would be the one piece of this book that quietly disagreed with the other two passes it's supposed to agree with. The fix is the same two-pass shaperesolve_programandexec_programalready use: compile every top-levelStmt_Funcfirst, in one pass over the statement list, then compile everything else in file order, skipping the functions already handled:compile_program :: proc(chunk: ^Chunk, stmts: []Stmt, allocator := context.allocator) { c := compiler_init(chunk, allocator) for s in stmts { if _, ok := s.(^Stmt_Func); ok do compile_stmt(&c, s) } for s in stmts { if _, ok := s.(^Stmt_Func); ok do continue compile_stmt(&c, s) } chunk_write_op(c.chunk, .Nil, 0) chunk_write_op(c.chunk, .Return, 0) }This is exactly the kind of bug the disassembler alone can never catch: the compiled bytecode for
func a's body looks completely correct in isolation (GET_GLOBAL "b"; CALL 0; RETURN), and it is correct, the problem is purely about what order two other pieces of bytecode run in. It only surfaced by actually running the compiled output (a throwaway stack-machine harness, not Chapter 13's real VM yet) and comparing it against the tree-walker's answer for the same program. Take the lesson, not just the fix: cross-checking a new pass's output against a slower, already- trusted one is worth doing before the "real" consumer of that output exists, not after.
12.2 Calls, and recursion for free
Compiling a call is: compile the callee (whatever expression it is, a bare name, a parameter holding a function value, anything), then compile each argument left to right, then emit Call with the argument count:
case ^Expr_Call:
compile_expr(c, node.callee)
for arg in node.args do compile_expr(c, arg)
chunk_write_op(c.chunk, .Call, node.line)
chunk_write(c.chunk, u8(len(node.args)), node.line)
fib, recursive, disassembles cleanly with nothing chapter-specific added for recursion at all:
./run.sh compile examples/fib.mus
== fib ==
0000 0004 GET_LOCAL 0000
0002 0004 CONSTANT 0000 '2'
0004 0004 LESS
0005 0004 JUMP_IF_FALSE 000007
0008 0004 POP
0009 0005 GET_LOCAL 0000
0011 0005 RETURN
0012 0004 JUMP 000001
0015 0004 POP
0016 0007 GET_GLOBAL 0001
0018 0007 GET_LOCAL 0000
0020 0007 CONSTANT 0002 '1'
0022 0007 SUB
0023 0007 CALL 0001
0025 0007 GET_GLOBAL 0003
0027 0007 GET_LOCAL 0000
0029 0007 CONSTANT 0004 '2'
0031 0007 SUB
0032 0007 CALL 0001
0034 0007 ADD
0035 0007 RETURN
0036 0003 NIL
0037 0003 RETURN
== <script> ==
0000 0003 CONSTANT 0000 '<fn fib>'
0002 0003 DEFINE_GLOBAL 0001
0004 0009 GET_GLOBAL 0002
0006 0009 GET_GLOBAL 0003
0008 0009 CONSTANT 0004 '10'
0010 0009 CALL 0001
0012 0009 CALL 0001
0014 0009 POP
0015 0000 NIL
0016 0000 RETURN
fib's own two recursive calls at 0016-0034 read fib via GET_GLOBAL, exactly the same instruction any other global read compiles to; there's no special "self-call" opcode and no special-casing anywhere in compile_expr's Expr_Call case for the callee happening to be the function currently being compiled. This is the whole payoff of Chapter 13's design (previewed here because it explains why this just works): every call, recursive or not, becomes a fresh frame pushed onto one shared call-frame stack, at run time, not something the compiler has to reason about at all. A compiler that had to specifically detect and handle recursion would be solving a problem this design never creates in the first place.
12.3 Closures, honestly scoped
Chapter 8 called tree-walk closures "nearly free," and flagged, three chapters early, that bytecode closures wouldn't be. This is that chapter, and the honest version of the claim is shorter than it sounds, because of a decision made back in Chapter 4.
Design decision: capturing by value, not full lexical closures over mutable outer locals. Alternatives considered: full closures with upvalues (Lua's own term for exactly this mechanism): a nested function captures a reference to an outer local, so mutating that local after the closure is created, or from inside the closure itself, is visible on both sides. Trade-off: a tree-walking interpreter gets this almost for free, an
Environmentis already a live, shared, heap-allocated object (Chapter 8), so a closure just keeps a pointer to one that already exists. A bytecode VM's locals live on a flat, reused value stack (section 11.1), which gets popped and overwritten the moment a call frame returns; an outer local a nested function wanted to keep referencing by reference would need it "hoisted" onto the heap the moment any closure captures it, exactly the real, nontrivial mechanism Lua's own VM implements as upvalues, closed over a stack slot while it's live, then "closed" (copied off the stack) at the moment the frame that owned it returns. Choice: Muspel captures by value, at the moment a closure is created, not by reference, and doesn't support mutating a captured outer variable from inside the closure afterward. This is precisely the subset a game-scripting callback (on_death(entity, fn), Chapter 17) actually needs, read an outer value once, remember it, done, without Lua-style upvalues' real engineering cost: no "open" versus "closed" upvalue state machine, no need to hoist a captured local off the stack at all. The honest cost: a closure that captures a counter and expects incrementing it to be visible to a second closure sharing that counter (a common pattern in languages with true upvalues) simply isn't expressible in Muspel. Appendix D notes where a reader wanting full upvalues would start.
The practical consequence for this book's grammar (Chapter 4 restricts func to top-level declarations; nested function expressions don't exist yet) is that this design decision has nothing to actually capture: there is no outer local for a top-level function to close over, because there is no enclosing function it could be nested inside. examples/callbacks.mus demonstrates the part of "functions as values" Muspel's grammar does support, calling a function through a value that isn't its own name, without needing closures over locals at all:
./run.sh compile examples/callbacks.mus
== add ==
0000 0004 GET_LOCAL 0000
0002 0004 GET_LOCAL 0001
0004 0004 ADD
0005 0004 RETURN
0006 0003 NIL
0007 0003 RETURN
== apply ==
0000 0008 GET_LOCAL 0000
0002 0008 GET_LOCAL 0001
0004 0008 GET_LOCAL 0002
0006 0008 CALL 0002
0008 0008 RETURN
0009 0007 NIL
0010 0007 RETURN
== <script> ==
0000 0003 CONSTANT 0000 '<fn add>'
0002 0003 DEFINE_GLOBAL 0001
0004 0007 CONSTANT 0002 '<fn apply>'
0006 0007 DEFINE_GLOBAL 0003
0008 0011 GET_GLOBAL 0004
0010 0011 GET_GLOBAL 0005
0012 0011 GET_GLOBAL 0006
0014 0011 CONSTANT 0007 '2'
0016 0011 CONSTANT 0008 '3'
0018 0011 CALL 0003
0020 0011 CALL 0001
0022 0011 POP
0023 0000 NIL
0024 0000 RETURN
apply's own body reads f with a plain GET_LOCAL 0000, its own first parameter, then calls whatever function value the caller passed in. Nothing about compiling that call knows or cares that f happens to hold add's value at run time rather than some other function; that's exactly the same "callee is any expression" generality Chapter 8's tree-walker already had, and it comes free here too, for the same reason: Expr_Call's callee compiles like any other expression, full stop.
Worth noting explicitly, because it's easy to assume it needs special handling and satisfying that it doesn't: a struct or array argument, or return value, needs nothing from this chapter beyond what's already here. Call/Return move whatever's sitting on the stack, an f64, a ^Func_Proto, or (once Chapter 13's VM exists to run any of this) a ^Struct_Instance, identically; a function that takes a Vec2 and returns a Vec2 compiles exactly like one that takes and returns a number, because by the time Call runs, a struct instance is just another Value sitting where an argument belongs.
Checkpoint
musc compile correctly compiles functions, recursive calls, and functions passed around and called through parameters, matching the tree-walker's already-established semantics for every example this book has built so far, examples/fib.mus and examples/callbacks.mus both disassemble exactly as this chapter's listings show.
Exercises
- Write a function with more than one
returnon different branches (an early return inside anif, followed by a fallthroughreturnat the end) and confirm the compiled chunk still ends with the fallbackNil; Returnthe fallthrough path needs, even though it's unreachable from the early-return path. compile_function's parameters are declared viadeclare_local, in order, before the body compiles. Trace, on paper, why a two-parameter function's first parameter always lands in slot0and its second in slot1, using section 11.1'sCompiler_Localstack directly, before checkingexamples/callbacks.mus'sapply(three parameters,f,x,y) against your prediction.- Section 12.3's Design decision says Muspel can't express two closures sharing a mutable captured counter. Try to write a
.musprogram that would need that (without worrying about whether it compiles), and explain, using the section's own reasoning, exactly which part of the mechanism it would need that Muspel's current grammar has no way to ask for. fib(n - 1) + fib(n - 2)compiles both calls before theAdd. Confirm, from the disassembly, that the first call's result is safely below the second call's own arguments on the stack while that second call runs, rather than being clobbered by it. (You don't have a VM yet to run this and watch it happen, Chapter 13 is where you'll get to; for now, reason it through from the bytecode alone.)
Further reading
- Crafting Interpreters,
Calls and Functions, the direct model for compiling function declarations and call sites to bytecode: https://craftinginterpreters.com/calls-and-functions.html - Crafting Interpreters,
Closures, for the full upvalue mechanism section 12.3's Design decision deliberately doesn't implement, read as the "what it would take to go further" companion to this chapter's honest scope cut: https://craftinginterpreters.com/closures.html - Lua's own upvalue design, the real production version of exactly the mechanism this chapter names and declines: "The Implementation of Lua 5.0" (Ierusalimschy, de Figueiredo, Celes), section 4, "Closures."