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

class WispClass: def __init__(self, name, superclass, methods): self.name = name self.superclass = superclass # WispClass or None self.methods = methods # dict: name -> WispFunction 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) # walk UP the chain return None def call(self, interpreter, arguments): # calling the CLASS constructs an instance instance = WispInstance(self) initializer = self.find_method("init") if initializer is not None: initializer.bind(instance).call(interpreter, arguments) return instance class WispInstance: def __init__(self, klass): self.klass = klass self.fields = {} # per-instance data, set via 'this.x = ...' def get(self, name): if name in self.fields: return self.fields[name] method = self.klass.find_method(name) if method is not None: return method.bind(self) # the key line -- covered below raise WispRuntimeError(f"undefined property '{name}'")

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.

Verified directly — a class, an instance, a field set in init, and a method reading it
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():

def bind(self, instance): env = Environment(self.closure) # a FRESH environment env.define("this", instance) return WispFunction(self.declaration, env, self.is_initializer) # a FRESH function

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.

Verified directly — two instances of the same class keep independent state
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.

def bind(self, instance): # BUGGY self.closure.define("this", instance) # mutates the SHARED closure in place return self # returns the SAME function object every time

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.

Verified directly — a real, reproduced bug: two "independent" stored methods collapsing into one
Running the identical 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.
This is the same shape as Chapter 7's own over-capture bug, one layer up
Chapter 7 showed reusing one call environment across multiple calls to the same function breaking independence between calls. This is the identical mistake at the method-binding layer: reusing one bound-method object across multiple instances breaks independence between instances. Both bugs come from the same root cause — treating something that needs to be created fresh, per logical "instance" of a concept, as safe to create once and share.

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.

Verified directly — an overridden method wins locally; an unoverridden one is found up the chain
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 initCat("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.

Verified directly — an explicit return inside init is ignored
class Weird { init(x) { this.x = x; return "ignored"; } } var w = Weird(42); print w.x; prints 42Weird(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 findingWhat it connects to
bind() creates a fresh Environment(self.closure), wrapping the method's own closureChapter 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 instancesChapter 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 superclassChapter 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 chapterAn 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

Exercise 1

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?

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

Chapter 8 Quick Reference

  • New pieces: ClassStmt, Get/Set expressions, WispClass (callable → constructs), WispInstance (a fields dict)
  • Verified: a class, instance, init-set field, and method read all work end to end
  • this: just Variable("this"), bound fresh via WispFunction.bind() on every single property access — a new environment, a new function object, every time
  • Verified — the over-mutation bug: binding this by 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 the superclass chain recursively — verified override, inheritance, and inherited init all working correctly
  • init: always returns the instance, regardless of any explicit return inside it — verified
  • Next chapter: Error Handling & Runtime Diagnostics — giving every WispRuntimeError raised since Chapter 4 a real source line to report