Viscosity
Review
Part IV · A bytecode virtual machine · Day 15

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.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • 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

Intuition

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.

Definition

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:

Step throughA method call, with and without a bound method
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 value stack as a row of slots, the new frame under it, and a list of heap allocationsVALUE STACKGET_GLOBAL c<script>0c123HEAP ALLOCATIONS FOR THESE CALLSnone
c.add(2) compiles to three instructions. First the receiver: GET_GLOBAL pushes the instance c.
1/6

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:

Try itA field beats a method
class A {
  m() { print "method"; }
}
fn f() { print "field"; }
let a = A();
a.m();
a.m = f;
a.m();
Definition

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.

Under the hood

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

ExerciseAn account that earns interest

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;
Extra practiceCopy-down inheritance

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_CLASS and OP_METHOD build a class, and this is slot 0 of every method.
  • Calling a class allocates an instance in the callee’s slot and calls the initializer, init, which always returns this.
  • Method invocation: OP_INVOKE calls 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_INHERIT copies the superclass’s methods down once; super is a local captured as an upvalue, used by OP_SUPER_INVOKE.

Check your understanding

0/6
  1. 1.

    What does c.taste() compile to?

  2. 2.

    When a class is called, where does the VM put the new instance?

  3. 3.

    class P { init(x) { this.x = x; } } What does print P(1).init(2); print?

  4. 4.

    Why does OP_INVOKE check the receiver’s fields before its class’s methods?

  5. 5.

    How does a subclass find an inherited method at run time?

  6. 6.

    Inside a Dog method, how is super reached?

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)