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
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:
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.
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.
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.
Property Access: Get and Set
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
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.
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.
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 compiled — self.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?
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 finding | What it connects to |
|---|---|
this resolves through ordinary resolve_local/resolve_upvalue | Chapters 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 + 1 | Chapter 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 bug | Course 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 opcode | Chapter 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
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.
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.
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.
Chapter 9 Quick Reference
- New types:
WispClass(callable, constructs instances),WispInstance(afieldsdict),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 ownbase = origin + 1— reusing the old formula silently pointedthisat 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 returnthisat compile time — an explicit conflictingreturnvalue is never even compiled, not just overridden at runtime- Verified — fair, isolated comparison: a lightweight
BoundMethodpair is 2.27× cheaper per access than allocating a fresh closure the way a tree-walking interpreter's ownbind()does - Next chapter: Capstone — benchmarking this finished VM against Course 1's own tree-walking interpreter, on the same program