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:

  1. A frame for adder is created with x = 5.
  2. The FnExpr for add captures that frame as its closure.
  3. adder returns the FnValue.

When add5(3) is called:

  1. A new frame is created with add5's closure (the adder frame) as parent.
  2. y = 3 is defined in that frame.
  3. x + y resolves: y is in the current frame, x is in the captured adder frame.

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.