← back to blog

Closures in first didn't need a special case

first just got fn function literals, calls, and — almost as a side effect — closures and recursion. I went in expecting closures to be the hard part: some kind of capture list, a snapshot of the variables a function references, maybe a whole new heap structure to own that snapshot's lifetime. None of that happened. Closures fell out of a struct that's been sitting in environment.c since before functions existed at all.

The Environment was already there

Environment backs every scope in first — the top-level program, and every { } block. It's a small struct: a pointer to a parent scope, and a map of names to values.

// src/environment.c
struct Environment {
  struct Environment *parent;
  JMap *map;
};

The map is a JMap — the same open-addressing hash map I wrote about a few weeks ago. Looking up or declaring a name walks up the parent chain until it finds a scope whose map has it:

// simplified from src/environment.c
Environment *resolve(Environment *self, char *name) {
  if (jmap_has(self->map, name)) {
    return self;
  }
  if (self->parent == NULL) {
    return NULL;
  }
  return resolve(self->parent, name);
}

This is exactly what makes a { } block able to see variables from its enclosing scope while keeping its own declarations from leaking back out. It was built for blocks. Functions just needed the same trick applied to a scope that outlives the block it was created in.

What a function literal actually stores

When the interpreter evaluates an fn(params) -> body literal, it doesn't build anything closure-specific. It wraps the AST node and the current environment into a RuntimeValue, and that's the whole function:

// src/interpreter.c
RuntimeValue *eval_function_literal(AST *ast, Environment *env) {
  RuntimeValue *rtv = malloc(sizeof(RuntimeValue));

  rtv->tag = FUNCTION;
  rtv->data.FUNCTION.definition = ast;
  rtv->data.FUNCTION.closure = env;

  return rtv;
}

Notice what's not there: no loop over free variables, no copying of bindings, no new allocation for the captured scope. closure is just the Environment * that was already alive when the literal was evaluated — the same pointer, not a snapshot of what it held. First-class functions and closures turned out to be the same feature: once a function is a value that carries a scope pointer around with it, capturing outer variables isn't extra work, it's just what "carrying a scope pointer" already means.

Aside: struct FUNCTION in runtime.h declares both fields as void *, not AST * and Environment *. runtime.h doesn't include parser.h or environment.h, so it can't name those types — interpreter.c casts them back on the way out. It's a small price for keeping the runtime value representation from having to know about the AST or the scope chain at all.

Calling in: a child of the closure, not the caller

The other half of the mechanism is where the call's parameter scope gets attached. eval_call creates a fresh environment for the call — as a child of the closure, not of whatever environment the call expression happens to be sitting in:

// src/interpreter.c
AST *fn = (AST *)callee->data.FUNCTION.definition;
Environment *closure = (Environment *)callee->data.FUNCTION.closure;

Environment *call_scope = environment_create(closure);

for (int32_t i = 0; i < pcount; i++) {
  /* bind each parameter name into call_scope */
}

return interpreter_evaluate(fn->data.AST_FUNCTION.body, call_scope);

That one line — environment_create(closure) — is lexical scoping. A free variable inside the function body resolves by walking call_scope, then closure, then closure's parent, and so on: the chain of scopes that existed when the function was written. The scope at the call site never enters into it at all.

let x = 10
let add_x = fn(y) -> x + y

add_x(5) # → 15, resolved from where add_x was defined

Recursion, as a timing accident that works out

Recursion looked like it would need its own fix — how does fact call fact when fact doesn't exist yet while its own body is being parsed? It turns out the existing evaluation order for let already handles it, by luck of pointer semantics rather than by design:

// src/interpreter.c
RuntimeValue *eval_variable_declaration(struct AST_DECLARATION declaration,
                                        Environment *env) {
  char *name = declaration.name->data.AST_IDENTIFIER.name;
  RuntimeValue *value = interpreter_evaluate(declaration.init, env);

  return environment_declare_variable(env, name, value);
}

The initializer is evaluated before the name is declared. For let fact = fn(n) -> ..., that means the closure is created over env while fact still doesn't exist in it. But declare_variable then inserts fact into that exact same env — not a copy, the same JMap the closure is already holding a pointer to. By the time anyone actually calls fact, the name has long since landed in the environment its own closure points at.

let fact = fn(n) -> when
  n <= 1 -> 1,
  _ -> n * fact(n - 1)

fact(5) # → 120

There's no forward-declaration step, no two-pass binding. The gap between "closure captured" and "name exists" is real, but nothing ever looks inside that gap — evaluating the initializer and calling the function are separated by at least one more statement, which is enough.

The same trick has a cost, and functions inherited it for free too

The scope chain doesn't just grant visibility — environment_declare_variable uses that same resolve() walk to reject redeclaration anywhere in the ancestor chain, not just the current scope. That rule predates functions, and it applies to parameters without anyone having written a line of code to make it apply:

let x = 10
let f = fn(x) -> x + 1

f(5) # error: `x` is already declared and cannot be declared again

A parameter named x is declared into call_scope, whose parent chain includes the closure — and the closure, in this example, already has an x in it. What looks like ordinary shadowing in most languages is a redeclaration error here, for exactly the same reason a nested { } block can't shadow an outer variable either. Functions didn't get a special exemption, because nobody wrote one. Reusing the block's scoping rule wholesale means reusing its rough edge too.

What's still missing

The mechanism is minimal enough that a few things don't work yet, simply because nothing routes through it:

None of those need a redesign, though. The scope-chain pointer that already gives first closures and recursion is the same thing any of them would build on. See the Functions section of the docs for the current syntax and the full list of sharp edges.