Classes, Instances & Method Dispatch in Bytecode

Writing a Compiler/Interpreter: Advanced

Chapter 9 · Classes, Instances & Method Dispatch in Bytecode

Course 1, Chapter 8 built classes on top of what its own tree-walking interpreter already had — a callable value (calling a class constructs an instance, exactly like calling a function runs its body), plus this bound via a fresh Environment and a fresh WispFunction on every single property access. This chapter builds the same feature set — declaration, instantiation, fields, methods, single inheritance — on top of what this course already has: chunks, call frames, locals, and closures. The mechanism ends up genuinely lighter, and one real bug surfaced while getting there.

Three New Runtime Types

class WispClass: def __init__(self, name, superclass=None): self.name = name self.superclass = superclass self.methods = {} # name -> Closure def find_method(self, name): if name in self.methods: return self.methods[name] if self.superclass is not None: return self.superclass.find_method(name) return None class WispInstance: def __init__(self, klass): self.klass = klass; self.fields = {} class BoundMethod: def __init__(self, receiver, method): self.receiver = receiver; self.method = method # method: a Closure

find_method is line-for-line the same recursive walk Course 1's own WispClass used — this chapter changes nothing about how inheritance is resolved, only how the resulting method gets bound to a specific instance once found.

this Is Just Slot 0 — No Special-Casing Anywhere

Course 1's own chapter made a point of it: this "isn't a keyword with special evaluation rules — it's parsed straight into Variable('this')." This chapter takes that literally. Compiling a method declares a synthetic local named "this" before its real parameters:

def compile_function(self, node, is_initializer, is_method): func_compiler = Compiler(enclosing=self, enclosing_scope_depth=1) func_compiler.is_initializer = is_initializer if is_method: func_compiler.declare_local("this") # ALWAYS slot 0 for a method for param in node.params: func_compiler.declare_local(param) # slots 1, 2, ... # ... compile the body exactly like any other function ...

Every reference to this inside a method body now resolves through resolve_local — the exact same code Chapter 4 wrote for any ordinary local, and Chapter 7's resolve_upvalue for a nested function closing over this. Nothing new had to be built for name resolution at all.

A Real Bug: Methods Need a Different Frame Base Than Functions

The first attempt reused Chapter 6's own call convention unchanged: new_base = len(stack) - arg_count, with the receiver simply overwriting the callee's own stack slot the way Chapter 6 never needed to. Running this.name = name; inside init immediately raised RuntimeError: Only instances have fields.

The actual cause — slot 0 pointed one position too late
For an ordinary function call, the callee's own value is discarded — it never becomes a local, so slot 0 correctly means "the first real argument," one position after the callee. For a method or constructor call, the receiver replaces the callee's own slot and becomes slot 0 itself — one position earlier than Chapter 6's formula assumed. Reusing that formula unchanged pointed OP_GET_LOCAL 0 at the first real argument instead of the receiver — for init, that meant this resolved to whatever value init's own parameter happened to be, not an instance at all.

The fix: a CallFrame tracks two positions, not one — base (where slot 0 actually lives, used by OP_GET_LOCAL/OP_SET_LOCAL) and origin (the callee's own original position, used only to know how much to discard on return). For a plain function call, base = origin + 1. For a method or constructor call, base = origin — the receiver is slot 0.

Verified directly — the fix resolved it, matching Course 1's own Greeter example exactly
class Greeter { init(name) { this.name = name; } greet() { return "hello, " + this.name; } } var g = Greeter("wisp"); print g.greet(); runs to ['hello, wisp'], matching Course 1's own verified result for the identical program.

Compiling a Class Declaration

OP_CLASS pushes a new, empty class. An optional superclass expression is compiled and consumed by OP_INHERIT. Each method compiles through the same compile_function used above, wrapped in OP_CLOSURE (methods can capture upvalues too, exactly like any nested function), then attached via OP_METHOD — all while the class itself sits on the stack, untouched, underneath.

elif instr == OP_METHOD: name = constants[code[frame.ip]]; frame.ip += 1 method_closure = self.stack.pop() self.stack[-1].methods[name] = method_closure # the class is still right there

Property Access: Get and Set

elif instr == OP_GET_PROPERTY: name = constants[code[frame.ip]]; frame.ip += 1 instance = self.stack.pop() if name in instance.fields: self.stack.append(instance.fields[name]) # a field wins first else: method = instance.klass.find_method(name) if method is None: raise RuntimeError(f"Undefined property '{name}'.") self.stack.append(BoundMethod(instance, method)) # a lightweight PAIR, nothing more

That last line is the whole method-binding mechanism. No new environment, no new closure, no re-wrapping of anything — just a receiver and a method, held together. a.increment() compiles as an ordinary Get feeding an ordinary Call: nothing about method calls needed a dedicated opcode at all, since a BoundMethod flows through the exact same OP_CALL every other callable value uses.

Verified: Independent Instances, and a Bug This Design Can't Reproduce

Verified directly — matches Course 1's own correct result, never its own found bug
Course 1's own Counter example — two instances, two stored bound methods, called out of order: incA(), then incB() — runs to ['1', '101'], exactly matching Course 1's own correct result. Course 1's own chapter separately documented a real bug in an earlier, buggy version of its own bind() — mutating one shared closure in place instead of creating a fresh one, collapsing two instances' bound methods into one (['101', '102']). That specific failure mode has no equivalent here at all: OP_GET_PROPERTY allocates a brand-new BoundMethod object on every single access, unconditionally — there is no shared, mutable object anywhere in this design for two instances to accidentally collide on.
Verified directly — single inheritance, override, and fallback all resolve correctly
Dog < Animal overrides speak; Cat < Animal doesn't. d.speak()/c.speak(): ['Rex barks.', 'Whiskers makes a sound.']. A three-level chain, C < B < A, with only A defining whoAmI: C().whoAmI() correctly returns "A", found two levels up.

init Always Returns this — Decided at Compile Time

Course 1 solved this at runtime: WispFunction.call() checks self.is_initializer after catching a ReturnException and substitutes this for whatever the return actually carried. This chapter's own compiler can make the identical guarantee earlier — by never compiling the return value the programmer wrote in the first place.

def visit_return_stmt(self, node): if self.is_initializer: self.chunk.write(OP_GET_LOCAL, node.line) self.chunk.write(0, node.line) # slot 0 -- 'this' -- ALWAYS, regardless of node.value elif node.value is not None: self.visit(node.value) else: # push nil self.chunk.write(OP_RETURN, node.line)
Verified directly — an explicit return inside init is compiled away entirely, never merely overridden
class Weird { init(x) { this.x = x; return "ignored"; } } var w = Weird(42); print w.x; runs to 42. The string "ignored" is never even compiledself.visit(node.value) is never called for it — rather than being computed and then discarded at runtime the way Course 1's own is_initializer substitution does it.

A Fair, Isolated Performance Comparison

Chapter 1 already established that a naive Python-hosted VM doesn't automatically outrun a tree-walking interpreter — comparing two entirely different execution models is easy to get wrong. A fairer question for this chapter specifically: within the same VM, how much does the lightweight BoundMethod pairing actually save over allocating a fresh closure the way Course 1's own bind() does — an Environment-equivalent plus a WispFunction-equivalent, every single property access?

Verified directly — the lightweight pairing is a real, measured 2.27x cheaper per access
20,000 isolated allocations, median of 7 runs: a BoundMethod(receiver, method) pair — 0.098 µs/access. A fresh Upvalue plus a fresh Closure wrapping the same method — mirroring the shape of Course 1's own "new environment, new function object" cost, without a full cross-architecture comparison's other confounding variables — 0.223 µs/access. A 2.27× difference, isolating just this one design choice.

Where This Connects

This chapter's findingWhat it connects to
this resolves through ordinary resolve_local/resolve_upvalueChapters 4 and 7's own name-resolution machinery, reused completely unchanged — the single biggest reason this chapter needed no new resolution logic at all
Methods and constructors need base = origin, not base = origin + 1Chapter 6's own call convention, revised a second time (the first was Chapter 7's frame-relative local addressing) — a real bug, found and fixed the same way, by actually running the code rather than assuming the existing formula would generalize
A BoundMethod is immune, by construction, to Course 1's own over-mutation bugCourse 1, Chapter 8's own found-and-fixed bug — not avoided by extra caution here, but structurally impossible, since nothing is ever mutated in place
a.increment() needs no dedicated method-call opcodeChapter 6's own OP_CALL, which already had to handle multiple callee types — a BoundMethod is simply a third kind, alongside Closure and WispClass

Hands-On Exercises

Exercise 1

Compile and run class Counter { init(startAt) { this.count = startAt; } increment() { this.count = this.count + 1; return this.count; } } var a = Counter(5); print a.increment();. Hand-trace the stack contents and the values of origin and base for both the Counter(5) call and the a.increment() call, showing exactly which stack position holds this in each.

📄 View solution
Exercise 2

Reproduce this chapter's own isolated allocation-cost comparison (a BoundMethod pair versus a fresh Upvalue + Closure pair, 20,000 times each). Confirm the ratio lands close to this chapter's own reported 2.27×, and explain in your own words why the heavier version's cost comes specifically from object allocation rather than from anything about method dispatch logic itself.

📄 View solution
Exercise 3

Using class A { whoAmI() { return "A"; } } class B < A {} class C < B {}, instrument WispClass.find_method to count its own recursive calls, then run C().whoAmI(). Determine the total call count, and explain why it isn't simply 1 (for finding whoAmI) plus 1 (for the init lookup during construction) — trace through exactly what each recursive step costs.

📄 View solution

Chapter 9 Quick Reference

  • New types: WispClass (callable, constructs instances), WispInstance (a fields dict), BoundMethod (receiver + method, nothing more)
  • this: a synthetic local at slot 0 of every method — resolved through Chapter 4/7's own existing machinery, no special-casing
  • Real bug, found and fixed: methods and constructors need base = origin (receiver is slot 0), not Chapter 6's own base = origin + 1 — reusing the old formula silently pointed this at the wrong stack position
  • Verified: Greeter, independent-instance Counter, and Animal/Dog/Cat inheritance all match Course 1's own correct results exactly
  • Structural immunity: Course 1's own over-mutation bug (bound methods collapsing across instances) cannot occur here — every property access allocates a fresh BoundMethod, unconditionally
  • init: forced to return this at compile time — an explicit conflicting return value is never even compiled, not just overridden at runtime
  • Verified — fair, isolated comparison: a lightweight BoundMethod pair is 2.27× cheaper per access than allocating a fresh closure the way a tree-walking interpreter's own bind() does
  • Next chapter: Capstone — benchmarking this finished VM against Course 1's own tree-walking interpreter, on the same program