59. Classes and methods in the VM
Classes come to the VM as two hash tables and a handful of opcodes. The interesting part is a shortcut: calling a method without ever making the bound-method object the language says exists.
- Describe how the VM represents classes and instances, and how OP_CLASS and OP_METHOD build a class.
- Explain what happens when a class is called, and why an initializer always returns this.
- Trace OP_INVOKE and explain why it needs no bound-method allocation.
- Explain copy-down inheritance and how super reaches the superclass.
The same cake, compiled
Here is chapter 43’s class again, now on the VM:
class Cake {
init(f) { this.f = f; }
taste() { print "Mmm, " + this.f; }
}
let c = Cake("lemon");
c.taste(); // Mmm, lemon
let t = c.taste;
t(); // Mmm, lemon
The disassembler shows a surprise in the calls. Here are
c.taste(); and let t = c.taste;:
OP_GET_GLOBAL 9 'c'
OP_INVOKE (0 args) 10 'taste'
...
OP_GET_GLOBAL 12 'c'
OP_GET_PROPERTY 13 'taste'
c.taste() and c.taste compile to different instructions. More on
that soon.
Classes are two tables
A class (ObjClass) is a heap object with a name and a hash table of
methods. An instance (ObjInstance) has a pointer to its class,
klass, and a hash table of fields. They are chapter 51’s tables, keyed by interned strings (chapter 52), so
finding "taste" compares the key by identity, not character by
character.
To build a class, the compiler emits OP_CLASS, which pushes a new,
empty class, and then, for each method, OP_CLOSURE for its body and
OP_METHOD with its name. OP_METHOD stores the closure on top of the
stack into the class just below it, and pops the closure.
Inside a method, this is just a local. When the compiler starts a
method, it reserves slot 0 and names it this (in a plain function,
slot 0 holds the function itself and has no usable name). So this.f
compiles to OP_GET_LOCAL 0, then OP_GET_PROPERTY 'f'. Reading a
property checks the fields first, then the class’s methods:
case OP.GET_PROPERTY: {
const inst = peek(0); // checked
const name = readString();
const v = inst.fields.get(name);
if (v !== undefined) {
pop();
push(v);
} else {
bindMethod(inst.klass, name);
}
break;
}
bindMethod allocates an ObjBoundMethod holding the instance and the
method’s closure: chapter 43’s bound method, as a heap object. That’s
what let t = c.taste; builds, and why t() still knows its this.
Calling a class
A bakery order. You get a new, empty cake box, someone fills it, and what comes back across the counter is always the box.
Drip has no new: calling a class makes an instance. callValue
handles it:
case 'class': {
const at = this.sp - argCount - 1;
this.stack[at] = heap.newInstance(callee);
const init =
callee.methods.get(this.initString);
if (init !== undefined) {
return this.call(init, argCount);
}
if (argCount !== 0) {
this.runtimeError(/* Expected 0 ... */);
}
return;
}
The new instance replaces the class in the callee’s slot, which becomes
the new frame’s slot 0. So when init runs, this and the parameters
are already in place. initString is
the interned "init" that chapter 58 kept as a root, so this lookup
doesn’t allocate a string.
What does init return? The compiler makes sure it is always the
instance. A function’s implicit return normally pushes nil, but in an
initializer it pushes slot 0:
function emitReturn() {
if (fc().type === 'initializer')
emit(OP.GET_LOCAL, 0);
else emit(OP.NIL);
emit(OP.RETURN);
}
A bare return; inside init uses emitReturn too, so it also returns
this. Returning anything else is a compile error, Can't return a value from an initializer. And calling a class that has no init with arguments fails at run time
with Expected 0 arguments but got 1.
An initializer is the method named init. Calling a class
allocates an instance, stores it in the callee’s stack slot (which
becomes the method’s slot 0, this), and calls init with the
arguments. The compiler makes every return from init return slot 0,
so the call always produces the instance; return value; in init is a
compile error.
Invoking a method directly
Back to the surprise. c.taste() is a property read followed by a call.
Done literally, that is OP_GET_PROPERTY (allocate a bound method) and
then OP_CALL (unpack it again). The bound method lives for a moment
and becomes garbage straight away.
So the compiler spots the pattern. When it parses .name and the next
token is (, it emits one OP_INVOKE name argCount instead:
private invoke(name, argCount) {
const receiver = this.peek(argCount);
// (checked: an instance)
const field = receiver.fields.get(name);
if (field !== undefined) {
const at = this.sp - argCount - 1;
this.stack[at] = field;
return this.callValue(field, argCount);
}
this.invokeFromClass(
receiver.klass, name, argCount);
}
invokeFromClass finds the method in the class’s table and calls it.
The receiver is already sitting just below the arguments, in the slot
that becomes this. Nothing to allocate, nothing to unpack. Compare the
two routes:
class Acc {init() { this.n = 0; }add(k) { this.n = this.n + k; }}let c = Acc();c.add(2);let f = c.add;f(3);
The field check is there to keep the meaning unchanged. A field can
hold a function, and a.m() must call the field if there is one, just
as reading a.m and then calling it would:
class A {
m() { print "method"; }
}
fn f() { print "field"; }
let a = A();
a.m();
a.m = f;
a.m();Method invocation in the VM is a single instruction,
OP_INVOKE name argCount, emitted when a property access is
immediately called. It looks the name up in the receiver’s fields, then
in its class’s methods, and calls the method with the receiver already
in slot 0. It does exactly what OP_GET_PROPERTY plus OP_CALL would
do, but without allocating a bound method.
Inheritance by copying
The tree-walker found an inherited method by walking up the superclass
chain on every lookup. The VM does that walk once. OP_INHERIT copies
the superclass’s whole method table into the subclass:
case OP.INHERIT: {
const sup = peek(1); // checked: a class
const sub = peek(0) as ObjClass;
sub.methods.addAll(sup.methods);
pop(); // the subclass
break;
}
It runs before the subclass’s own OP_METHODs, so an overriding
method simply overwrites the copied entry. After that, every lookup is a
single table probe. This works because a Drip class can’t gain methods
after its declaration, so the copy can never go stale.
And super? When a class has a superclass, the compiler opens a scope
around the class body and declares a local named super holding it.
Methods that use super capture that local as an upvalue (chapter 57),
and it is closed when the class declaration ends. super.speak() in a
Dog method compiles to:
OP_GET_LOCAL 0
OP_GET_UPVALUE 0
OP_SUPER_INVOKE (0 args) 0 'speak'
Push this, push the superclass, then invoke speak starting from that
class. Like OP_INVOKE, OP_SUPER_INVOKE allocates nothing.
Even with OP_INVOKE, every Drip method call still hashes into a table.
JavaScript engines avoid repeating that work with shapes and inline
caches: objects with the same layout share a shape, and each property
access site remembers the shapes it has seen and where the property was
found, so the next access with the same shape skips the
lookup[2]. Drip’s classes, OP_INVOKE and
copy-down inheritance follow clox from Crafting
Interpreters[1].
Your turn
Fill in the four method bodies so the program prints Ada then 18. Account.init(owner) stores owner and a balance of 0. deposit(x) adds x to the balance and returns this, so calls can chain. Savings.init(owner, rate) reuses Account’s init and stores rate. Savings.deposit(x) deposits x plus x * rate by reusing Account’s deposit.
class Account {
init(owner) {
// owner, and a balance of 0
}
deposit(x) {
// add x; return this
}
}
class Savings < Account {
init(owner, rate) {
// reuse Account's init
}
deposit(x) {
// deposit x + x * rate
}
}
let s = Savings("Ada", 0.5);
s.deposit(10).deposit(2);
print s.owner;
print s.balance;Model OP_INHERIT with Maps. Write makeClass(sup, own), where sup is the superclass’s method Map (or null) and own is a Map of the new class’s own methods. Return a new Map with the inherited methods copied in first and the class’s own methods on top, so overrides win. Don’t change sup or own.
function makeClass(sup, own) {
const methods = new Map();
// copy down, then add own methods
return methods;
}What you learned
- A class is a heap object with a method table; an instance has a
class pointer and a field table.
OP_CLASSandOP_METHODbuild a class, andthisis slot 0 of every method. - Calling a class allocates an instance in the callee’s slot and calls
the initializer,
init, which always returnsthis. - Method invocation:
OP_INVOKEcalls a method straight from the class’s table, with the receiver already in place, so no bound method is allocated. Fields are checked first, so the meaning is unchanged. OP_INHERITcopies the superclass’s methods down once;superis a local captured as an upvalue, used byOP_SUPER_INVOKE.
Check your understanding
0/6- 1.
What does
c.taste()compile to? - 2.
When a class is called, where does the VM put the new instance?
- 3.
class P { init(x) { this.x = x; } }What doesprint P(1).init(2);print? - 4.
Why does
OP_INVOKEcheck the receiver’s fields before its class’s methods? - 5.
How does a subclass find an inherited method at run time?
- 6.
Inside a
Dogmethod, how issuperreached?
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)