06 — First-Class Functions and Closures
A language has first-class functions when functions can be:
- Stored in variables
- Passed as arguments
- Returned from functions
- Constructed at runtime
MiniLang supports all four, and the implementation is a small addition to what step 05 already built.
The Value type
struct FnValue {
std::vector<std::string> params;
StmtPtr* body; // pointer into the AST; AST is stable
std::shared_ptr<Environment> closure; // captured scope
};
using Value = std::variant<
std::monostate, // nil
double, // number
bool,
std::string,
std::shared_ptr<FnValue>
>;
FnValue holds the parameter list, a pointer to the function body in the
AST, and the captured environment at definition time. The closure field
is the one shared_ptr chain that makes closures work.
Calling a function
Value Interpreter::visitCall(CallExpr& call) {
Value callee = evaluate(*call.callee);
auto fn = std::get<std::shared_ptr<FnValue>>(callee); // or throw type error
if (call.args.size() != fn->params.size()) throw ...;
// Build the call frame with the closure as parent.
auto frame = std::make_shared<Environment>(fn->closure);
for (size_t i = 0; i < fn->params.size(); ++i)
frame->define(fn->params[i], evaluate(*call.args[i]));
auto saved = env_;
env_ = frame;
try { execute(*fn->body); }
catch (ReturnSignal& r) { env_ = saved; return r.value; }
env_ = saved;
return std::monostate{}; // nil if no return statement
}
ReturnSignal is a C++ exception used as a non-local transfer out of
visitBlock chains when a return statement fires. It's not an error —
it's a structured jump. This avoids threading a "should I keep executing?"
flag through every interpreter method.
Closures capture the environment, not variables
fn adder(x) {
fn add(y) { return x + y; }
return add;
}
let add5 = adder(5);
let add10 = adder(10);
print add5(3); // 8
print add10(3); // 13
When adder(5) is called:
- A frame for
adderis created withx = 5. - The
FnExprforaddcaptures that frame as itsclosure. adderreturns theFnValue.
When add5(3) is called:
- A new frame is created with
add5's closure (theadderframe) as parent. y = 3is defined in that frame.x + yresolves:yis in the current frame,xis in the capturedadderframe.
add10 has its own separate adder frame with x = 10. The two closures
don't share state.
The mutation case
fn makeCounter() {
var count = 0;
fn inc() { count = count + 1; return count; }
return inc;
}
let c = makeCounter();
print c(); // 1
print c(); // 2
Here count = count + 1 inside inc calls env_->set("count", ...).
set walks the parent chain, finds count in the captured makeCounter
frame, and updates it in place. The next call to c() builds a new call
frame with the same closure, sees the updated count, and returns 2.
Mutable closures work for free with the parent-chain set semantics.
Tail calls and stack overflow
cp-03 does not implement tail-call optimisation. A deep recursion like
fib(40) will produce a tall C++ call stack and may segfault before
finishing. This is a known limitation — the solution is continuation-
passing or explicit stack in the interpreter, deferred to later labs.