Viscosity
Review
Part III · A tree-walking interpreter · Day 11

41. Closures in Drip

One field on a function value, and one line in its call, give Drip closures. The surprise is what that does to environments: they stop dying when their call ends.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Explain which environment a Drip function stores, and why it is the one current when the function is declared.
  • Trace a call whose new environment hangs off the function’s closure, and predict what it can see.
  • Explain why an environment can outlive the call that made it, and what keeps it alive.
  • Predict and fix closures that share one loop variable in Drip.

A counter, in Drip

In chapter 6 you wrote makeCounter in JavaScript. Here it is in Drip:

fn makeCounter() {
  let n = 0;
  fn count() {
    n = n + 1;
    return n;
  }
  return count;
}

let a = makeCounter();
print a(); // 1
print a(); // 2

Drip functions are values, so makeCounter can hand count back. Then a() runs count’s body, which uses n. But n was a local of a call that has already returned. Where does the interpreter find it?

Everything hangs on one decision. When a call starts, it gets a fresh environment for its parameters. Every environment has a parent. Which environment should be the parent of a call’s environment?

Three possible parents

Intuition

A function is like a letter written in a particular house. When you read it later, the words “the kettle” mean the kettle in that house, the one it was written in. Not the house of whoever happens to open it, and not some town hall everyone shares.

There are three candidates:

  1. The globals. Simple, but then count can’t see n at all. Looking up n searches the call’s environment, then the globals, and fails with Undefined variable 'n'.
  2. The caller’s environment. Then what a name means depends on who calls the function. Chapter 6’s show/run example would print inner. That rule has a name, dynamic scope, and Drip doesn’t use it.
  3. The environment the function was declared in. That is lexical scope, the rule JavaScript follows[2], and the rule Drip follows too.

Option 3 has a cost: the function must remember where it was declared. That’s the closure field chapter 39 gave DripFunction and promised to explain.

The closure field

You’ve seen both halves of it in chapter 39. Look at them again with closures in mind. When the interpreter runs a fn declaration, it stores the current environment in the new function value:

case 'function':
  this.env.define(s.name.lexeme,
    new DripFunction(s, this.env));
  return;

And when the function is called, the new environment hangs off that stored one, not off whatever environment the caller is in:

class DripFunction {
  constructor(
    readonly decl: FunctionDecl,
    readonly closure: Environment,
  ) {}
  call(interp, args: Value[]): Value {
    const env = new Environment(
      this.closure,
    );
    this.decl.params.forEach((p, i) =>
      env.define(p.lexeme, args[i]),
    );
    return interp.executeFunctionBody(
      this.decl, env,
    );
  }
}

That’s the whole feature: one field, filled in at declaration, and used as the parent at every call. Inside makeCounter, the current environment is the call’s own, so that is what count keeps. Step through it:

Step throughThe environment a closure keeps alive
fn makeCounter() {
let n = 0;
fn count() {
n = n + 1;
return n;
}
return count;
}
let a = makeCounter();
print a();
print a();
Environments as boxes with parent arrows; the count function points at the environment of the makeCounter callENVIRONMENTSFUNCTION VALUESglobalsmakeCounter = <fn>
Declaring makeCounter makes a DripFunction. Its closure field is the environment that was current at that moment: the globals.
1/8
Definition

A captured environment is the environment a function value stores when it is created: the one that was current when its declaration ran. In the companion code it is the closure field of DripFunction. Every call of the function makes a new environment whose parent is the captured one, so the body sees the names around where it was written. The function plus its captured environment is a closure.

Environments that outlive their call

Look again at step 5. makeCounter has returned. So have the interpreter’s own TypeScript calls that ran its body, like executeFunctionBody, and their frames on the host stack (chapter 40) are gone. Yet the environment with n in it is still there.

That’s because an Environment is not in that stack frame. It is an ordinary object, made with new, so it lives on the heap (chapter 17). The frame only held a reference to it. A heap object stays alive as long as something still refers to it, and count.closure does. When nothing refers to it any more (say a is reassigned), the host language’s garbage collector is free to reclaim it.

Definition

Environment lifetime is how long an environment exists. In a tree-walking interpreter, environments are heap objects, so an environment lives as long as anything references it: the running call, a child environment, or a function’s captured environment. It can therefore outlive the call that created it.

Two functions made in the same call share one captured environment, and so they share its variables, live:

Try itTwo closures, one environment
let get;
let set;
fn makeBox(v) {
  fn g() { return v; }
  fn s(x) { v = x; }
  get = g;
  set = s;
}

makeBox("old");
print get();
set("new");
print get();

Closures in a for loop

In chapter 6, each pass of a JavaScript for (let i ...) loop got its own i. Drip is different, and you can see why from chapter 38: a Drip for is desugared into a block that declares i once, wrapped around a while loop.

let f1;
let f2;
for (let i = 1; i <= 2; i = i + 1) {
  fn show() { print i; }
  if (i == 1) f1 = show;
  else f2 = show;
}
f1(); // 3
f2(); // 3

The loop body is a block, so each pass gets a new body environment. But i isn’t in it. It lives one level out, in the environment of the desugared block, and there is only one of those. To give each function its own value, make a call per pass, because every call makes a new environment:

fn capture(v) {
  fn show() { print v; }
  return show;
}
Left: two functions made in a for loop each point at their own body environment, but both body environments have the same parent, which holds the only i, now 3. Right: two functions made by calls to capture each point at their own call environment, holding v = 1 and v = 2.MADE IN THE LOOPMADE BY capture(i)show #1show #2pass 1 bodypass 2 bodyloop’s environmenti = 3show #1show #2call 1v = 1call 2v = 2prints 3, then 3prints 1, then 2
New environments come from calls and blocks, not from loop passes. Left: each show has its own body environment, but both have the same parent, where the only i ends at 3. Right: each call of capture makes an environment of its own.
Under the hood

Your interpreter gets closures almost for free because it keeps every environment on the heap and lets the host’s garbage collector decide when one is dead. The price is that every call allocates a new Environment with its own Map, even for a function that never makes a closure. You’ll measure what that costs in chapter 44.

The bytecode VM in Part IV makes the opposite choice. It keeps locals in a fast stack, which is exactly where a variable can’t survive its call. So it has to spot variables that closures capture and move them off the stack when the call returns. The idea is the same as here; only the bookkeeping changes. Drip, and this two-interpreter plan, follow the Lox language from Crafting Interpreters[1].

Your turn

ExerciseOne i each

This Drip program saves three functions from a loop, but every one prints 4. Change it so that f1(), f2() and f3() print 1, 2 and 3. Keep the loop and the three calls at the end.

let f1;
let f2;
let f3;
for (let i = 1; i <= 3; i = i + 1) {
  fn show() { print i; }
  if (i == 1) f1 = show;
  if (i == 2) f2 = show;
  if (i == 3) f3 = show;
}
f1();
f2();
f3();
Extra practiceCalling with a captured environment

A tiny model of the interpreter in plain JavaScript. Env is given. A function value is { params, body, closure }, where body(env) computes the result. Write callFn(f, args): make a new Env whose parent is f.closure, define each parameter in it, and return f.body(env).

class Env {
  constructor(parent) {
    this.vars = new Map();
    this.parent = parent;
  }
  get(name) {
    if (this.vars.has(name)) {
      return this.vars.get(name);
    }
    if (this.parent) {
      return this.parent.get(name);
    }
    throw new Error('no ' + name);
  }
}

function callFn(f, args) {
  // your code here
}

What you learned

  • A Drip function value stores its captured environment: the one current when its declaration ran.
  • Each call makes a new environment whose parent is that captured environment, not the globals and not the caller’s. That is lexical scope.
  • Environments are heap objects, so their lifetime is “as long as something refers to them”. A closure can keep one alive long after its call has returned.
  • Closures made in one call share variables, live.
  • A Drip for declares its variable once, so closures made in the loop share it. A call per pass gives each its own.

Next: a bug hiding in this design, and a whole extra pass over the tree that fixes it.

Check your understanding

0/6
  1. 1.

    When does a Drip function capture its environment?

  2. 2.

    What does this print?

    let x = "outer";
    fn show() { print x; }
    fn run() {
      let x = "inner";
      show();
    }
    run();
  3. 3.

    What keeps makeCounter’s call environment alive after the call returns?

  4. 4.

    A function made inside a call is called. What is the parent of its new call environment?

  5. 5.

    Three functions are made in one Drip for (let i = 1; i <= 3; i = i + 1) loop and each prints i. Called after the loop, what do they print?

  6. 6.

    In the tree-walker, which of these is true of environments?

References

  1. [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
  2. [2] MDN contributors. Closures (JavaScript Guide). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Closures (checked 2026-10-31)