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.
- 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
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.
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.
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.
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,
);
}
class Cake {init(f) { this.flavour = f; }taste() {print this.flavour;}}let c = Cake("lemon");c.taste();
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:
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.
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
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();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
initto 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.
thisis an ordinary variable, found at a distance the resolver computed.bindcreates 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.
What does a
DripInstancecontain? - 2.
What does this print?
class Dog { bark() { print "woof"; } } fn quiet() { print "..."; } let d = Dog(); d.bark = quiet; d.bark(); - 3.
In
c.taste(), when istastebound toc? - 4.
Inside a method body (not inside a nested block or function), how many environments up is
this? - 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.
What does every
c.taste()call allocate in the tree-walker, before the call itself?
References
- [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] Mathias Bynens, Benedikt Meurer (2018). JavaScript engine fundamentals: Shapes and Inline Caches. mathiasbynens.be. mathiasbynens.be/notes/shapes-ics (checked 2026-10-31)
- [3] MDN contributors. this. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this (checked 2026-10-31)