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

43. Classes and instances in Drip

Two maps and one small environment are all Drip needs for objects. And because of that environment, a Drip method never forgets its this.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Describe how the interpreter represents a class, an instance and its fields.
  • Trace a property read through fields first, then the class’s methods.
  • Explain how binding a method creates an environment holding this, and why a Drip method keeps its this when passed around.
  • Predict what an initializer returns and which init mistakes the resolver rejects.

A method that doesn’t forget

Here is a Drip class. It looks a lot like chapter 7’s JavaScript:

class Cake {
  init(flavour) {
    this.flavour = flavour;
  }
  taste() {
    print "Mmm, " + this.flavour;
  }
}

let c = Cake("lemon");
c.taste();  // Mmm, lemon
let f = c.taste;
f();        // Mmm, lemon

Two differences. There is no new: you call the class like a function, and init plays the constructor. And look at the last line. In JavaScript, const f = p.move; f(); lost its this, because this is decided by the dot in each call[3]. In Drip, f() still knows it belongs to c. By the end of this chapter you’ll know exactly which object makes that work.

Classes, instances and fields

Intuition

A class is a recipe card pinned to the wall; an instance is one cake on the counter. Each cake carries its own decorations (fields). When someone asks a cake “how do you taste?”, and it has no answer written on itself, it points at the recipe card.

Two Cake instances, each with its own fields map, both pointing at one Cake class that holds the methods map. A property read checks the fields first, then the class's methods.Cake instanceflavour = "lemon"Cake instanceflavour = "plum"class Cakeinit, taste1. fields: own data2. then methods
Fields live on the instance; methods live on the class. Each instance has its own map of fields and a pointer to its class. The class holds the methods, once, for every instance.

A class statement makes a DripClass: a name and a Map from method name to DripFunction. Calling the class makes a DripInstance, binds init to it, runs init and returns the instance:

call(interp, args: Value[]): Value {
  const inst = new DripInstance(this);
  this.findMethod('init')
    ?.bind(inst).call(interp, args);
  return inst;
}

(The class’s arity is init’s arity, or 0 if there is no init.) An instance is just its class plus a second Map:

class DripInstance {
  fields = new Map<string, Value>();
  constructor(readonly klass: DripClass) {}
  get(name: Token): Value {
    const n = name.lexeme;
    if (this.fields.has(n))
      return this.fields.get(n)!;
    const m = this.klass.findMethod(n);
    if (m) return m.bind(this);
    throw new RuntimeError(name,
      `Undefined property '${n}'.`);
  }
  set(name: Token, value: Value) {
    this.fields.set(name.lexeme, value);
  }
}

Fields are never declared. b.size = 3 simply creates size if it isn’t there yet. Reading checks the fields first, so a field can hide a method of the same name. In the interpreter, a get expression like c.taste first checks that the object is an instance at all; if not, you get Only instances have properties.

Definition

A Drip class is a runtime value holding a name and a map of methods. Calling it creates an instance, which holds a pointer to its class and a map of fields: per-instance data, created simply by assigning to them. Reading obj.name returns the field if there is one, otherwise the class’s method, otherwise it is a runtime error.

Try itFields come and go
class Box {}

let b = Box();
b.size = 3;
print b.size;
b.size = b.size + 1;
print b.size;
print b;
print b.colour;

this, and bound methods

How does taste’s body find this? Not by a special rule. this is looked up like any other variable, in an environment, at a distance the resolver worked out. The resolver wraps every class’s methods in one extra scope that declares this:

case 'class':
  declare(s.name);
  define(s.name);
  begin();
  scopes.at(-1)!.set('this', true);
  for (const m of s.methods)
    fn(m, m.name.lexeme === 'init'
      ? 'initializer' : 'method');
  end();

So at run time there must be a matching environment with this in it, between a method’s body and the environment where the class was declared. It is made by bind, every time a method is read off an instance:

bind(inst: DripInstance) {
  const env = new Environment(
    this.closure,
  );
  env.define('this', inst);
  return new DripFunction(
    this.decl, env, this.isInitializer,
  );
}
Step throughBinding a method creates an environment for this
class Cake {
init(f) { this.flavour = f; }
taste() {
print this.flavour;
}
}
let c = Cake("lemon");
c.taste();
Environments on the left: call, this-environment, globals. Objects on the right: the class, the instance and the bound method.ENVIRONMENTSOBJECTSclass Cakemethods: init, tasteglobalsCake
The class statement makes a DripClass with a map of methods. Each method is a DripFunction whose closure is the environment the class was declared in: here, the globals.
1/8
Definition

A bound method is a method together with the instance it was read from. Drip makes one by wrapping the method’s captured environment in a new environment whose only variable is this, and returning a new function value that captures it. The binding happens when the method is read, so this travels with the value wherever it goes.

Now the puzzle from the start is solved. let f = c.taste stored the bound method, and its closure holds this = c. Calling f() later, with no dot, makes no difference. The same is true when you pass a method as a callback:

Try itA method as a callback
class Tally {
  init() { this.n = 0; }
  add() { this.n = this.n + 1; }
}

fn twice(f) {
  f();
  f();
}

let t = Tally();
twice(t.add);
print t.n;
print t.add == t.add;

Notice the last line: each read of t.add binds again, so it makes a new function value, and two of them aren’t the same object.

init always returns this

init is marked as an initializer (that isInitializer flag), and an initializer’s call always returns the this from its closure, whatever its body does. So calling c.init("plum") directly re-runs it and gives you c back. For the same reason, return "x"; inside init is a resolver error: Can't return a value from an initializer. A bare return; is fine.

Under the hood

Every this.flavour in Drip is a hash-map lookup by name in that instance’s own Map. JavaScript engines do better for objects that share a shape: objects with the same properties, added in the same order, share one hidden class, which records where each property sits, so a property read can go straight to a fixed position[2]. Drip has no shapes. Part IV’s VM still keeps fields in a hash table, its own this time.

And there’s a hidden cost in get: every method read allocates two objects, a new environment and a new DripFunction, even for an immediate call like c.taste(). Next chapter, you’ll see what that costs in real benchmarks. Drip’s classes follow Lox’s design from Crafting Interpreters[1].

Your turn

ExerciseA counter class

Finish the Drip class Tally. init sets a field n to 0, add() adds 1 to it, and value() returns it. The program below it must print 2, 3 and 0. Notice that twice(t.add) passes a method as a callback.

class Tally {
  init() {}
  add() {}
  value() {}
}

fn twice(f) {
  f();
  f();
}
let t = Tally();
twice(t.add);
print t.value();
t.add();
print t.value();
print Tally().value();
Extra practiceFields, then methods

Model DripInstance.get in plain JavaScript. An instance is { fields: Map, klass: { methods: Map } }. Each method is a JS function whose first parameter is the instance. Write getProp(inst, name): return the field if there is one; otherwise return the method bound to inst (a function that calls the method with inst first, then the arguments); otherwise throw an Error.

function getProp(inst, name) {
  // your code here
}

What you learned

  • A Drip class is a name plus a map of methods. Calling it makes an instance, binds init to it and runs it.
  • An instance holds its class and a map of fields. Fields appear when you assign them, and a read checks fields before methods.
  • this is an ordinary variable, found at a distance the resolver computed. bind creates the environment that holds it.
  • A bound method carries its instance with it, so a Drip method never loses its this, even as a callback.
  • An initializer always returns this; returning a value from it is a resolver error.

Next: classes that build on other classes, super, and a hard look at how fast all of this really is.

Check your understanding

0/6
  1. 1.

    What does a DripInstance contain?

  2. 2.

    What does this print?

    class Dog {
      bark() { print "woof"; }
    }
    fn quiet() { print "..."; }
    let d = Dog();
    d.bark = quiet;
    d.bark();
  3. 3.

    In c.taste(), when is taste bound to c?

  4. 4.

    Inside a method body (not inside a nested block or function), how many environments up is this?

  5. 5.

    What does this print?

    class P {
      init(x) { this.x = x; }
    }
    let p = P(1);
    let q = p.init(7);
    print q == p;
    print p.x;
  6. 6.

    What does every c.taste() call allocate in the tree-walker, before the call itself?

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] Mathias Bynens, Benedikt Meurer (2018). JavaScript engine fundamentals: Shapes and Inline Caches. mathiasbynens.be. mathiasbynens.be/notes/shapes-ics (checked 2026-10-31)
  3. [3] MDN contributors. this. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this (checked 2026-10-31)