Classes & Object-Oriented Features
Writing a Compiler/Interpreter: Fundamentals
Chapter 8 · Classes & Object-Oriented Features
A class in Wisp is, structurally, not far from what Chapter 7 already built: a callable value (calling it constructs an instance, the same way calling a function runs its body) that owns a dictionary of methods, each of which is itself a WispFunction. The genuinely new problem this chapter has to solve is this — a method needs to know which specific instance it's currently running against, and getting that binding wrong is easy to do in a way that looks correct until two instances exist at once.
Three New Pieces: Classes, Instances, Properties
Fields aren't declared anywhere — WispInstance.fields starts empty, and a property is created the first time something is assigned to it via this.x = ... inside a method. Get (instance.field) and Set (instance.field = value) are two new expression nodes, parsed at the same precedence level as function calls — after a primary expression, the parser loops checking for either a following ( (a call) or a following . (a property access), so a.b.c() parses correctly without any special grammar rule for chaining.
class Greeter { init(name) { this.name = name; } greet() { return "hello, " + this.name; } } var g = Greeter("wisp"); print g.greet(); prints hello, wisp.
this: Bound Fresh, Every Single Access
this isn't a keyword with special evaluation rules — it's parsed straight into Variable("this") in primary(), and resolved through the ordinary Environment chain like any other name. The entire mechanism is WispFunction.bind(), called every time a method is looked up on an instance via WispInstance.get():
Every property access that resolves to a method produces a brand-new WispFunction object, wrapping a brand-new environment, whose enclosing points back at the method's own original closure (the environment active when the class itself was declared) — with exactly one new binding, this, layered on top for this specific instance.
class Counter { init(startAt) { this.count = startAt; } increment() { this.count = this.count + 1; return this.count; } } var a = Counter(0); var b = Counter(100); var incA = a.increment; var incB = b.increment; print incA(); print incB(); prints ['1', '101'] — incA and incB are two genuinely different WispFunction objects, each with its own bound this, even though both came from the exact same increment method declaration.
The Over-Mutation Bug: Binding in Place Instead of Creating Fresh
bind() looks like it's doing more allocation than necessary — a new environment and a new function object, every single time a method is touched. The tempting shortcut: define this directly into the method's own existing closure, and return the same function object unchanged.
self.closure here is the environment that was active when the class itself was declared — the same environment every instance's increment method shares, since there's only one increment declaration for the whole Counter class. Mutating it in place means every instance's own "binding" is actually the exact same shared slot, overwritten by whichever instance accessed the method most recently.
Counter program above with this buggy bind(), print incA(); print incB(); prints ['101', '102'] — not ['1', '101']. incB = b.increment; ran after incA = a.increment;, and its own call to bind() overwrote the shared closure's this from a to b. By the time incA() actually executes, incA and incB are — despite having come from two different instances — literally the same Python object, and both now operate on b's own count. a's data is never touched at all; the entire program silently forgets it exists.
Single Inheritance: A Chain of find_method() Calls
class Dog < Animal { ... } stores Animal's own WispClass as Dog.superclass. Nothing about method lookup changes structurally — find_method() just doesn't stop at self.methods if the name isn't there; it asks the superclass to look too, recursively, for as many levels as the inheritance chain goes.
class Animal { init(name) { this.name = name; } speak() { return this.name + " makes a sound."; } } class Dog < Animal { speak() { return this.name + " barks."; } } class Cat < Animal { } — Dog overrides speak; Cat doesn't. var d = Dog("Rex"); var c = Cat("Whiskers"); print d.speak(); print c.speak(); prints ['Rex barks.', 'Whiskers makes a sound.']. Cat also never defines its own init — Cat("Whiskers") works anyway, because WispClass.call()'s own find_method("init") walks the exact same chain and finds Animal's.
init Always Returns the Instance
A constructor's job is to set up an instance, not to compute a return value — so init is treated specially: even if its own body contains an explicit return statement, calling a class always yields the instance, never whatever init tried to return.
class Weird { init(x) { this.x = x; return "ignored"; } } var w = Weird(42); print w.x; prints 42 — Weird(42) itself evaluates to the instance, not the string "ignored", because WispFunction.call() checks self.is_initializer and substitutes self.closure.get("this") for whatever the ReturnException actually carried.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
bind() creates a fresh Environment(self.closure), wrapping the method's own closure | Chapter 7's own closure mechanism, reused directly — a bound method is a closure, just one whose captured environment happens to contain this |
| The over-mutation bug: one shared bound-method object across instances | Chapter 7's own over-capture bug (one shared call environment across function calls) and Chapter 5's NaiveEnvironment (a copy instead of a live reference) — three chapters, the same underlying mistake at three different layers |
find_method()'s recursive walk up superclass | Chapter 5's own Environment.get() walking up enclosing — structurally the identical pattern, applied to class hierarchies instead of lexical scopes |
No super.method() syntax in this chapter | An honest scope boundary — this chapter covers single inheritance and method resolution as outlined, not explicit superclass-method calls from inside an override; a real Wisp implementation would add a Super expression node following the same closure-binding pattern as this |
Hands-On Exercises
Call g.greet(1) against a class whose greet(a, b) method expects two parameters. Verify this raises the same kind of arity error Chapter 7 established for plain function calls, and trace through visit_call to confirm it applies identically to a bound method as it does to an ordinary function — is there any special-casing for methods anywhere in the arity check?
This chapter showed init ignoring an explicit return "ignored"; when the constructor completes normally. Trace through WispFunction.call()'s own is_initializer handling for both code paths — the except ReturnException branch and the "fell off the end with no return at all" branch — and explain why both need their own explicit substitution of self.closure.get("this") rather than one shared check being sufficient.
Build a three-level inheritance chain, class A { whoAmI() { return "A"; } } class B < A {} class C < B {}, and verify C().whoAmI() correctly finds A's method two levels up. Then verify what happens calling a method that exists nowhere in the entire chain, and explain how many recursive find_method() calls each scenario needs.
Chapter 8 Quick Reference
- New pieces:
ClassStmt,Get/Setexpressions,WispClass(callable → constructs),WispInstance(afieldsdict) - Verified: a class, instance,
init-set field, and method read all work end to end this: justVariable("this"), bound fresh viaWispFunction.bind()on every single property access — a new environment, a new function object, every time- Verified — the over-mutation bug: binding
thisby mutating a method's shared closure in place, instead of creating a fresh copy, collapses two stored, independent bound methods into one (['1','101'] becoming ['101','102']) - Inheritance:
find_method()walks thesuperclasschain recursively — verified override, inheritance, and inheritedinitall working correctly init: always returns the instance, regardless of any explicitreturninside it — verified- Next chapter: Error Handling & Runtime Diagnostics — giving every
WispRuntimeErrorraised since Chapter 4 a real source line to report