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

@dataclass class FunctionStmt(Stmt): # fun name(params) { body } name: str params: list body: list def accept(self, visitor): return visitor.visit_function_stmt(self) @dataclass class Call(Expr): # callee(arguments) callee: Expr arguments: list def accept(self, visitor): return visitor.visit_call(self) @dataclass class ReturnStmt(Stmt): # return expr; (expr may be omitted) value: Expr def accept(self, visitor): return visitor.visit_return_stmt(self)

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.

class WispFunction: def __init__(self, declaration, closure): self.declaration = declaration self.closure = closure # environment active AT DECLARATION time def call(self, interpreter, arguments): env = Environment(self.closure) # a FRESH environment, every single call for param, arg in zip(self.declaration.params, arguments): env.define(param, arg) try: interpreter.execute_block(self.declaration.body, env) except ReturnException as r: return r.value return None # fell off the end with no return -- implicit nil

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.

Verified directly — declaring, calling, and returning all work end to end
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.

class ReturnException(Exception): def __init__(self, value): self.value = value def visit_return_stmt(self, stmt): value = stmt.value.accept(self) if stmt.value is not None else None raise ReturnException(value)
Verified directly — a return statement correctly unwinds through nested control flow
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.

Verified directly — recursion works, computing genuinely correct values
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.

fun makeAdder(n) { fun adder(x) { return x + n; // 'n' isn't adder's own parameter -- it's captured } return adder; } var addFive = makeAdder(5); var addTen = makeAdder(10);

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.

Verified directly — two closures from two separate calls stay independent
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:

class OverCapturingWispFunction: def __init__(self, declaration, closure): self.declaration = declaration self.closure = closure self._shared_call_env = None # BUG: created once def call(self, interpreter, arguments): if self._shared_call_env is None: self._shared_call_env = Environment(self.closure) env = self._shared_call_env # BUG: reused every call for param, arg in zip(self.declaration.params, arguments): env.define(param, arg) ...

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.

Verified directly — a real, reproduced bug: two "independent" closures bleeding into each other
Running the exact same 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.
This is why "fresh environment per call" isn't optional, even though it looks like wasted allocation
Every call to any function gets its own new 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.

Verified directly — a closure over a repeatedly-mutated local variable
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 (acceptvisit_callWispFunction.callexecute_blockaccept → ...), so Wisp's own call depth is bounded by whatever bounds Python's.

Verified directly — a real, measured recursion limit, not a theoretical one
A recursive 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 RecursionErrornot 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 findingWhat it connects to
WispFunction.closure, captured as self.env at declaration timeChapter 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/whileChapter 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 callsChapter 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 stackCourse 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

Exercise 1

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?

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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?

📄 View solution

Chapter 7 Quick Reference

  • Three new nodes: FunctionStmt, Call, ReturnStmt
  • WispFunction: pairs a declaration with a closure environment; call() creates a fresh Environment(closure) every invocation
  • return: implemented as a raised ReturnException, caught only in WispFunction.call()verified unwinding cleanly through nested if/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 this gets bound inside a method