Functions & Closures
Writing a Compiler/Interpreter: Fundamentals
Chapter 7 · Functions & Closures
Everything built so far runs top to bottom, once. This chapter adds the ability to package up a sequence of statements, give it a name and parameters, and run it — possibly many times, possibly with different arguments each time, possibly calling itself. Functions turn out to need surprisingly little new machinery: one new callable class, one new exception type for return, and a careful answer to a question that's easy to get subtly wrong — when a function is declared inside another function, exactly which environment does it remember?
Three New Node Types
Call is parsed at a new precedence level between unary and primary — after parsing a primary expression, the parser checks for a following ( in a loop, so f(), and even a hypothetical f()(), both parse correctly without any special-casing.
WispFunction: A Real Callable
A Wisp function value isn't the FunctionStmt node itself — it's a wrapper object pairing that declaration with the environment that was active when the function was declared. That second piece is the closure, and it's the part this chapter spends most of its time on.
visit_function_stmt builds one of these when the fun statement executes: WispFunction(stmt, self.env) — self.env at that exact moment, not the global environment, not a copy. That single argument is the entire closure mechanism.
fun greet(name) { print "hello, " + name; } greet("wisp"); prints hello, wisp. A function that falls off its own end with no return statement produces nil: fun noReturn() { print "ran"; } var result = noReturn(); print result; prints ['ran', 'nil'].
return: An Exception, Not a Value
A return can appear anywhere inside a function body — nested three ifs and a while loop deep, and it still needs to immediately stop everything and hand a value back to whoever called the function. A normal Python return from visit_return_stmt can't do that; it would only stop the current statement, not unwind out of however many nested blocks and loops are currently executing. Raising a Python exception can, because Python's own exception handling already unwinds through exactly that kind of nesting.
fun findFirstOver(limit) { var i = 0; while (true) { if (i > limit) { return i; } i = i + 1; } print "never reached"; } print findFirstOver(5); prints 6. The return sits two levels deep — inside an if, inside a while — and the ReturnException passes cleanly through both visit_if_stmt and visit_while_stmt (neither one catches it) all the way up to WispFunction.call()'s own try/except. The trailing print "never reached"; is exactly that — never reached.
Recursion works the same way it does in Python, because a Wisp function call really is a Python function call. fun factorial(n) { if (n <= 1) { return 1; } return n * factorial(n - 1); } computing factorial(5) makes a real, recursive Python call to WispFunction.call() five levels deep.
print factorial(5); print factorial(10); prints ['120', '3628800'].
Closures Capture a Live Environment, Not a Copy
"Closure" means the function remembers the environment it was declared in, and keeps seeing that environment's own live updates — not a frozen snapshot of what it contained at declaration time.
Each call to makeAdder gets its own fresh call environment (that's Chapter 5's own Environment, unchanged). adder is declared inside that call, so its closure is that specific call's environment — self.env at the moment fun adder(x) {...} executes, which is genuinely different for the makeAdder(5) call than for the makeAdder(10) call.
print addFive(3); print addTen(3); prints ['8', '13'] — each adder correctly remembers its own n, even though both were declared from the exact same source line inside makeAdder.
The Over-Capture Bug: One Environment, Reused
The bug isn't in what gets captured — it's in when the call environment gets created. WispFunction.call() above creates Environment(self.closure) fresh, every single call. A tempting, subtly wrong "optimization" is to create it once and reuse it:
This looks harmless in isolation — every individual call to a single WispFunction still runs correctly. The bug only appears when a function that itself declares and returns closures — like makeAdder — is called more than once, because now every one of those returned closures shares the exact same underlying call environment object.
makeAdder program from above, but with makeAdder itself built using OverCapturingWispFunction, print addFive(3); print addTen(3); prints ['13', '13'] — not ['8', '13']. Calling makeAdder(10) second reused and overwrote the same shared environment's n from 5 to 10. addFive's own closure points at that identical shared object, so by the time addFive(3) actually runs, the n it sees has already been silently changed out from under it by an entirely separate, later call to makeAdder.
Environment in the correct implementation — including calls that don't return a closure at all, where reusing one might genuinely seem safe. The moment a function's own body can declare and return something that keeps a live reference to that call's environment, reuse becomes a correctness bug, not just a performance shortcut. There's no cheap way to tell in advance which functions will do that, so every call gets a fresh one, unconditionally.
The same live-capture mechanism also means a closure sees mutations made to a variable after the closure was declared, as long as they happen before the closure is called — closures over a mutable local, like a counter, work for exactly this reason.
fun makeCounter() { var count = 0; fun increment() { count = count + 1; print count; } return increment; } var counter = makeCounter(); counter(); counter(); counter(); prints ['1', '2', '3'] — the same count binding, correctly persisting and incrementing across three separate calls to the returned closure.
The Call Stack Is Python's Own
This interpreter never built anything resembling a call stack — no explicit list of active calls, no frame objects. Every Wisp function call is a real, nested Python call (accept → visit_call → WispFunction.call → execute_block → accept → ...), so Wisp's own call depth is bounded by whatever bounds Python's.
countDown(n) function, run at Python's actual default recursion limit (1000), correctly completes up to 163 levels of Wisp recursion before raising a genuine Python RecursionError — not 1000, because each single Wisp call costs several real Python stack frames (accept, visit_call, call, execute_block, another accept...) rather than one. Raising Python's own limit to 3000 pushed the measured working depth to 497. There is no Wisp-specific "stack overflow" check anywhere in this interpreter — the error is entirely Python's own, borrowed for free.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
WispFunction.closure, captured as self.env at declaration time | Chapter 5's own Environment.enclosing chain — a closure is nothing more than one more link that chain remembers, stored on a function value instead of discarded when a block ends |
ReturnException unwinding through nested if/while | Chapter 6's own control-flow statements needed no changes at all to support this — they simply never catch an exception that isn't theirs |
| The over-capture bug: one call environment reused across calls | Chapter 5's own NaiveEnvironment bug (a copy instead of a live reference) — a different mistake with the same root cause: sharing state between things that were supposed to be independent |
| Recursion depth bounded by Python's own stack | Course 2's own bytecode VM (Chapter 2 onward) explicitly manages its own call-frame stack instead of borrowing the host language's — a direct architectural consequence of not tree-walking anymore |
Hands-On Exercises
Write a recursive Fibonacci function, fun fib(n) { ... }, using the same base-case-then-recursive-case shape as this chapter's own factorial. Verify fib(10) against the well-known Fibonacci sequence, and count how many total recursive calls to fib happen for fib(10) — is it closer to 10, or dramatically more?
Run var x = "before"; fun show() { print x; } x = "after"; show();. Determine whether it prints before or after, and use it to explain precisely what "closures capture a live environment, not a snapshot of values" means for a case this chapter didn't directly cover — a variable reassigned after the function was declared but before it was ever called.
This chapter measured a maximum working recursion depth of 163 at Python's default limit and 497 at a limit of 3000. Using sys.setrecursionlimit() and this chapter's own countDown function, find the maximum working depth at a limit of 2000, and use the two known data points (1000→163, 3000→497) to predict it before checking. Is the relationship between Python's limit and Wisp's own usable depth linear?
Chapter 7 Quick Reference
- Three new nodes:
FunctionStmt,Call,ReturnStmt WispFunction: pairs a declaration with a closure environment;call()creates a freshEnvironment(closure)every invocationreturn: implemented as a raisedReturnException, caught only inWispFunction.call()— verified unwinding cleanly through nestedif/while- Verified: recursion works correctly —
factorial(10)→ 3628800 - Verified — closures: two closures from two separate calls to the same outer function stay independent (8 and 13, not both 13)
- Verified — the over-capture bug: reusing one call environment across calls instead of creating a fresh one makes independent closures bleed into each other
- Verified: Wisp's own recursion limit is Python's, inherited for free — measured at 163 levels (default) and 497 levels (limit raised to 3000)
- Next chapter: Classes & Object-Oriented Features — building on this exact closure mechanism for how
thisgets bound inside a method