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.

class WispFunction: def __init__(self, name, arity, chunk): self.name = name self.arity = arity # how many parameters it takes self.chunk = chunk # its OWN chunk -- a separate program
def visit_function_stmt(self, node): func_compiler = Compiler(enclosing_scope_depth=1) # starts as ITS OWN local scope for param in node.params: func_compiler.declare_local(param) # params become locals 0, 1, 2, ... for stmt in node.body: func_compiler.visit(stmt) # implicit "fell off the end" -> return nil idx = func_compiler.chunk.add_constant(None) func_compiler.chunk.write(OP_CONSTANT, node.line) func_compiler.chunk.write(idx, node.line) func_compiler.chunk.write(OP_RETURN, node.line) function = WispFunction(node.name, len(node.params), func_compiler.chunk) idx = self.chunk.add_constant(function) # the function VALUE is just another constant self.chunk.write(OP_CONSTANT, node.line) self.chunk.write(idx, node.line) # ... then OP_DEFINE_GLOBAL or declare_local, exactly like Chapter 4's var

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

def visit_call(self, node): self.visit(node.callee) # pushes the function value for arg in node.arguments: self.visit(arg) # pushes each argument, in order self.chunk.write(OP_CALL, node.line) self.chunk.write(len(node.arguments), node.line)

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.

class CallFrame: def __init__(self, function, ip, base): self.function = function self.ip = ip # THIS frame's own instruction pointer self.base = base # where THIS frame's locals begin on the shared stack # inside VM.run(): while True: frame = self.frames[-1] # whichever call is currently active code = frame.function.chunk.code # THIS frame's own bytecode instr = code[frame.ip]; frame.ip += 1 # ... dispatch, same as every earlier chapter ... elif instr == OP_CALL: arg_count = code[frame.ip]; frame.ip += 1 callee = self.stack[-(arg_count + 1)] new_base = len(self.stack) - arg_count self.frames.append(CallFrame(callee, 0, new_base)) # just a list append

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.

Verified directly — the exact, silent bug this causes if the fix is skipped
Reverting 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:

elif instr == OP_GET_LOCAL: slot = code[frame.ip]; frame.ip += 1 self.stack.append(self.stack[frame.base + slot]) # offset by THIS frame's own base elif instr == OP_SET_LOCAL: slot = code[frame.ip]; frame.ip += 1 self.stack[frame.base + slot] = self.stack[-1]

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.

elif instr == OP_RETURN: result = self.stack.pop() if self.stack else None finished_frame = self.frames.pop() if not self.frames: return result # the top-level script itself just finished del self.stack[finished_frame.base - 1:] # discard the function value, args, and locals self.stack.append(result) # leave just the return value where the call was

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.

Verified directly — declaration, parameters, return values, and the implicit-nil case all work end to end
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.

Verified directly — recursion far beyond both of this project's own previously-measured ceilings, with zero Python recursion anywhere
A recursive Wisp function summing 1 through n by calling itself — 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.
What actually bounds recursion now
Available memory for the frame list and the value stack — nothing else. This is the real, structural fix Chapter 1 forward-referenced: an explicit frame stack (a data structure the VM owns and controls) instead of borrowing the host language's own call stack (a resource the VM has no control over at all).

Multiple Functions, Not Just Recursion

Verified directly — three genuinely different functions calling each other thread through the frame stack correctly
a(5) calls b(6) calls c(7), computing 7 * 2: 14. Nothing about the frame mechanism is specific to a function calling itselfself.frames simply accumulates one entry per active call, whether that's the same function repeatedly or three unrelated ones.
Verified directly — a wrong argument count and a non-callable value both fail cleanly
Calling a 2-parameter function with only 1 argument: 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 findingWhat it connects to
50,000-level recursion, zero Python recursionCourse 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 indicesChapter 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 entryChapter 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

Chapter 6 Quick Reference

  • WispFunction: name, arity, and its own separately-compiled Chunk — 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 raw stack[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