Functions & Call Frames
Writing a Compiler/Interpreter: Advanced
Chapter 6 · Functions & Call Frames
Chapter 1 of this course made a promise it hadn't yet delivered on: Course 1's own tree-walking interpreter measured a real, hard ceiling of 163 levels for recursive Wisp function calls, because every nested call rode Python's own recursive call stack. This chapter is where that promise gets kept — real function declarations, real calls, and an explicit call frame stack that replaces Python's own recursion entirely. By the end, a Wisp function will be able to call itself tens of thousands of levels deep without Python's interpreter ever noticing.
A Wisp Function Is a Separately-Compiled Chunk
Every chunk so far has held one flat program. A function needs its own chunk — its body is a separate unit of code that only starts running when called, possibly many times, possibly never.
Two things worth noticing. First, a WispFunction is just another value in a constant pool — the same mechanism that already stores numbers and strings now stores whole compiled programs. Second, the function's own Compiler starts at scope_depth=1, not 0 — which means Chapter 4's own if scope_depth == 0: global check is automatically false for everything declared inside a function body, parameters included. A function's own locals are locals from the very first line, with no extra scoping code needed.
The Call Convention: Function, Then Arguments, on the Stack
By the time OP_CALL runs, the stack looks like [..., function, arg0, arg1, ...] — the function value sitting just below its own arguments.
Call Frames: an Explicit List, Not Python's Own Stack
This is the actual fix. Instead of the VM calling a Python function to run a Wisp function (which is exactly what would tie Wisp's own depth to Python's own recursion limit), the VM keeps a plain Python list of frames, and its own run() method is one loop, reading whichever frame is currently on top.
OP_CALL doesn't call anything, in the Python sense. It appends one more CallFrame to a list and lets the same while loop keep running — the next iteration just happens to read frame.function.chunk from the newly-pushed frame instead of the caller's. Nothing about the Python call stack changes at all, no matter how many Wisp-level calls are active.
A Necessary Fix: Locals Are Frame-Relative Now
Chapter 4's own OP_GET_LOCAL/OP_SET_LOCAL read stack[slot] directly — correct back then, because only one frame (the top-level script) ever existed, so a local's slot number and its actual stack position were the same number. With more than one frame possibly active, that's no longer true.
OP_GET_LOCAL to Chapter 4's own raw stack[slot] and running fun identity(n) { return n; } print identity(42);: the result is <fn identity> — the function object itself, not 42. Slot 0 should mean "the first local in this frame," but a raw index reads absolute position 0 in the whole stack, which is where the function value itself is still sitting (arguments are pushed after it, so the parameter n is actually at absolute position 1, not 0). No crash, no error — a completely wrong, confidently printed answer.
The fix is one word of arithmetic in two places:
frame.base was set the moment the call happened, to len(stack) - arg_count — exactly the position of the first argument, which is exactly where slot 0 is supposed to point. Every local reference now resolves relative to whichever frame is currently active, so the identical slot number 0 correctly means something different in every simultaneously-active call.
OP_RETURN's Real Meaning
Through Chapter 5, OP_RETURN just meant "the program is done, hand back whatever's on top of the stack" — true only because there was ever exactly one chunk running. Now it has to actually tear down a frame and hand control back to whoever made the call.
finished_frame.base - 1 is deliberate — it removes the callee's own function value too, not just its arguments and locals, since that value has no further use once the call is over. What's left afterward is exactly what a normal expression evaluation expects: one value, sitting where the call itself used to be.
fun add(a, b) { return a + b; } print add(3, 4); runs to ['7']. fun noReturn() { print 1; } var result = noReturn(); print result; runs to ['1', 'nil'] — falling off the end returns the implicit nil this chapter's own compiler always appends. A return two levels deep inside a while/if unwinds correctly too: fun findFirstOver(limit) { var i=0; while (true) { if (i > limit) { return i; } i = i + 1; } } called with 5 returns 6.
The Payoff: 50,000 Levels of Real Recursion
This is what all of it was for.
fun sumTo(n) { if (n < 1) { return 0; } return n + sumTo(n - 1); } — called with n = 10000: 50005000, correct, roughly 61× past Course 1 Chapter 7's own measured 163-level ceiling and roughly 20× past this course's own Chapter 1 measured 497-level tree-walking-eval ceiling. Pushed to n = 50000: 1250025000, correct, completing in 0.211 seconds. Python's own sys.getrecursionlimit() (1000 by default) never enters into it at all — VM.run() is one while loop, and every one of those 50,000 nested Wisp calls is just one more CallFrame appended to a Python list, not one more Python stack frame.
Multiple Functions, Not Just Recursion
a(5) calls b(6) calls c(7), computing 7 * 2: 14. Nothing about the frame mechanism is specific to a function calling itself — self.frames simply accumulates one entry per active call, whether that's the same function repeatedly or three unrelated ones.
RuntimeError: Expected 2 arguments but got 1. Calling a plain number as if it were a function: RuntimeError: Can only call functions, got float. Both checks run in OP_CALL, before a new frame is ever pushed — a bad call never gets the chance to corrupt the frame stack.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| 50,000-level recursion, zero Python recursion | Course 1, Chapter 7's own 163-level ceiling and this course's own Chapter 1, 497-level ceiling — both directly, finally resolved, exactly as Chapter 1 forward-referenced |
Local slots are frame-relative (frame.base + slot), not raw stack indices | Chapter 4's own single-frame simplification, now revised — and a real, silent, verified bug (a function value printed instead of a number) showing exactly why the revision was necessary, not cosmetic |
| A function value is just another constant-pool entry | Chapter 2's own constant pool, which already had to hold arbitrary Python values — this chapter is the first time that generality actually gets used for something other than a number or a name |
| Global name resolution happens at runtime, not compile time (Chapter 4) | What makes mutual recursion possible at all — a function can reference another function that hasn't been compiled yet, as long as it exists by the time the call actually runs |
Hands-On Exercises
Compile and run fun add(a, b) { return a + b; } print add(3, 4); using this chapter's own Compiler/VM. Trace, by hand, the exact contents of self.stack immediately before OP_CALL runs, immediately after the new CallFrame is pushed, and immediately after OP_RETURN finishes — including the value of frame.base for the add call.
Revert OP_GET_LOCAL/OP_SET_LOCAL to Chapter 4's own raw stack[slot] addressing (no frame.base offset), and run fun identity(n) { return n; } print identity(42); through the reverted VM. Confirm the wrong result this chapter's own warn-box reports, and explain precisely which value ends up sitting at absolute stack position 0 at the moment identity's own body runs, and why.
Compile and run two mutually recursive functions — isEven and isOdd, each calling the other, with isEven declared first — checking isEven(10) and isOdd(10). Explain why this works even though isEven's own body references isOdd by name before isOdd has been declared anywhere yet, tying your answer directly to a specific design decision from Chapter 4.
Chapter 6 Quick Reference
WispFunction: name, arity, and its own separately-compiledChunk— stored as just another constant-pool value- Call convention: function value, then each argument, pushed onto the shared stack;
OP_CALL <arg_count> CallFrame: function +ip+base— a plain Python list append/pop, never a Python function call- Verified fix: locals must be
frame.base + slot, not rawstack[slot]— reverting it silently returned a function object instead of a number - THE PAYOFF, verified: 50,000 levels of real recursive Wisp calls in 0.211s — ~300× past Course 1's own 163-level ceiling, ~100× past this course's own Chapter 1 ceiling, bounded only by memory
- Verified: arity mismatches and non-callable values both fail with a clean
RuntimeError, checked before any frame is pushed - Next chapter: Closures & Upvalues — the genuinely hard problem of a local variable that needs to outlive the function call that created it