MLIR for Lox: Part 7 - Classes and Instances
Classes are the last major feature in Crafting Interpreters. They combine everything we've built — heap allocation, GC roots, closures — and add a new layer: method dispatch, inheritance, and this binding.
This part assumes you've read Parts 2–6. We'll extend the GC runtime from Part 6 and the MLIR codegen from Part 4.
What We're Building
By the end, this Lox program should work:
class Doughnut {
cook(flavor) {
print "Frying " + flavor + " doughnut";
}
}
class FilledDoughnut < Doughnut {
cook(flavor) {
super.cook(flavor);
print "Injecting " + flavor + " filling";
}
}
var d = FilledDoughnut();
d.cook("custard");
Output:
Frying custard doughnut
Injecting custard filling
The Object Model
Lox has three class-related object types:
| Object | What It Holds | Analogy |
|---|---|---|
ObjClass | Name, methods, superclass | A blueprint |
ObjInstance | Class pointer, field table | A house built from the blueprint |
ObjBoundMethod | Receiver + closure | A method "bound" to an instance |
These are all heap-allocated, GC-managed objects. They extend the ObjHeader we built in Part 2.
Updated Object Types
#![allow(unused)] fn main() { // src/runtime/object.rs use std::cell::UnsafeCell; use std::collections::HashMap; use crate::runtime::value::Value; use crate::runtime::heap::{ObjHeader, ObjType}; /// A Lox class object pub struct ObjClass { pub header: ObjHeader, pub name: String, /// Methods defined directly on this class (not inherited) pub methods: HashMap<String, Value>, /// Superclass, if any (nil for root classes) pub superclass: Option<*mut ObjClass>, } impl ObjClass { /// Look up a method, walking the inheritance chain pub fn find_method(&self, name: &str) -> Option<Value> { if let Some(value) = self.methods.get(name) { return Some(*value); } // Walk up the inheritance chain if let Some(super_ptr) = self.superclass { // SAFETY: GC guarantees the object is alive when we have a reference unsafe { (*super_ptr).find_method(name) } } else { None } } } /// A Lox instance object pub struct ObjInstance { pub header: ObjHeader, /// The class this instance belongs to pub class: *mut ObjClass, /// Instance fields (set at runtime, not on the class) pub fields: UnsafeCell<HashMap<String, Value>>, } impl ObjInstance { pub fn new(class: *mut ObjClass) -> Self { Self { header: ObjHeader::new(ObjType::Instance), class, fields: UnsafeCell::new(HashMap::new()), } } /// Get a field value, or look up a method on the class pub fn get_property(&self, name: &str) -> Option<Value> { // Fields shadow methods let fields = unsafe { &*self.fields.get() }; if let Some(value) = fields.get(name) { return Some(*value); } // Look up method on the class unsafe { (*self.class).find_method(name).map(|method| { // Bind the method to this instance // (We'll define ObjBoundMethod below) Value::bound_method(self as *const ObjInstance as *mut ObjInstance, method) }) } } /// Set a field value pub fn set_property(&self, name: String, value: Value) { let fields = unsafe { &mut *self.fields.get() }; fields.insert(name, value); } } /// A method bound to a specific receiver instance pub struct ObjBoundMethod { pub header: ObjHeader, /// The instance that receives the method call (the `this` value) pub receiver: *mut ObjInstance, /// The underlying closure pub method: Value, } }
Updated ObjType Enum
#![allow(unused)] fn main() { // src/runtime/object.rs (continued) #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ObjType { String, Closure, // From Part 5 Class, Instance, BoundMethod, } }
GC Tracing for Classes
Every new object type needs to report its outgoing references. Miss one and you get use-after-free. This is the same trace function from Part 3, extended.
#![allow(unused)] fn main() { // src/runtime/gc.rs impl GC { /// Trace all reachable objects from `obj` pub fn trace_object(&mut self, obj: *mut ObjHeader) { let header = unsafe { &mut *obj }; if header.is_marked { return; } header.is_marked = true; match header.obj_type { ObjType::String => { // Strings have no outgoing references } ObjType::Closure => { let closure = unsafe { &*(obj as *const ObjClosure) }; // Trace the upvalue references (from Part 5) for &upvalue_ptr in &closure.upvalues { self.trace_value(unsafe { (*upvalue_ptr).closed }); } } ObjType::Class => { let class = unsafe { &*(obj as *const ObjClass) }; // Trace method values for value in class.methods.values() { self.trace_value(*value); } // Trace superclass if let Some(super_ptr) = class.superclass { self.trace_object(super_ptr as *mut ObjHeader); } } ObjType::Instance => { let instance = unsafe { &*(obj as *const ObjInstance) }; // Trace the class reference self.trace_object(instance.class as *mut ObjHeader); // Trace all field values let fields = unsafe { &*instance.fields.get() }; for value in fields.values() { self.trace_value(*value); } } ObjType::BoundMethod => { let bound = unsafe { &*(obj as *const ObjBoundMethod) }; // Trace the receiver instance self.trace_object(bound.receiver as *mut ObjHeader); // Trace the method closure self.trace_value(bound.method); } } } } }
Key insight: ObjClass traces its methods and its superclass. ObjInstance traces its class and all field values. ObjBoundMethod traces both the receiver and the closure. Every edge in the object graph must be walked.
this Binding
When a method is called, this refers to the receiver instance. We implement this the same way closures capture upvalues (Part 5) — the method's closure has an implicit upvalue that points to this.
How It Works
When we create a class, each method closure gets an extra upvalue slot for this. When a method is bound to an instance (via ObjBoundMethod), we fill that slot with the instance pointer.
#![allow(unused)] fn main() { // src/runtime/bind.rs use crate::runtime::object::{ObjBoundMethod, ObjInstance, ObjHeader, ObjType}; use crate::runtime::value::Value; use crate::runtime::heap::Heap; impl Heap { /// Bind a method closure to a receiver instance pub fn bind_method( &mut self, receiver: *mut ObjInstance, method: Value, ) -> *mut ObjBoundMethod { let bound = ObjBoundMethod { header: ObjHeader::new(ObjType::BoundMethod), receiver, method, }; self.allocate(bound) } } }
The Method Call Protocol
When the VM encounters a method call like d.cook("custard"):
- Evaluate
d→ get theObjInstancepointer - Look up
"cook"on the instance → get anObjBoundMethod - Call the bound method's closure with the provided arguments
- Inside the closure,
thisresolves to the bound receiver
No vtable needed. Method dispatch is just a hash map lookup that walks the superclass chain.
Inheritance: Just Linked Lists
Lox's inheritance is single-inheritance only. That means the class hierarchy is a linked list:
FilledDoughnut → Doughnut → nil
When we look up a method, we walk the chain:
#![allow(unused)] fn main() { // Already defined above in ObjClass::find_method pub fn find_method(&self, name: &str) -> Option<Value> { if let Some(value) = self.methods.get(name) { return Some(*value); } if let Some(super_ptr) = self.superclass { unsafe { (*super_ptr).find_method(name) } } else { None } } }
super Calls
A super.cook(flavor) expression needs two things:
- The superclass of the enclosing class (not the receiver's class)
- The method name
We resolve super at compile time, not runtime. During codegen, when we're inside a class method, we know which class we're in and therefore what the superclass is. We store this as a hidden upvalue on the closure — just like this.
#![allow(unused)] fn main() { // During compilation, inside a class method: // The method closure gets two implicit upvalues: // [0] = this (the receiver instance) // [1] = super (the enclosing class's superclass) }
This means super is free at runtime — no lookup needed. The superclass pointer is already captured in the closure.
MLIR Code Generation for Classes
Now the interesting part: generating MLIR for class declarations, instance creation, property access, and method calls.
New AST Nodes
#![allow(unused)] fn main() { // src/ast.rs (additions) #[derive(Debug, Clone)] pub enum Expr { // ... existing variants ... Get(GetExpr), Set(SetExpr), This(ThisExpr), Super(SuperExpr), } #[derive(Debug, Clone)] pub struct GetExpr { pub location: Location, pub object: Box<Expr>, pub name: String, } #[derive(Debug, Clone)] pub struct SetExpr { pub location: Location, pub object: Box<Expr>, pub name: String, pub value: Box<Expr>, } #[derive(Debug, Clone)] pub struct ThisExpr { pub location: Location, } #[derive(Debug, Clone)] pub struct SuperExpr { pub location: Location, pub method: String, } #[derive(Debug, Clone)] pub enum Stmt { // ... existing variants ... Class(ClassStmt), } #[derive(Debug, Clone)] pub struct ClassStmt { pub location: Location, pub name: String, pub superclass: Option<String>, pub methods: Vec<FunctionStmt>, } }
Runtime Calls as External Functions
Class operations are too complex for pure MLIR. We emit calls to runtime functions instead:
#![allow(unused)] fn main() { // src/codegen/classes.rs use melior::{ Context, Location, dialect::func, ir::{ attribute::{FlatSymbolRefAttribute, StringAttribute, TypeAttribute}, r#type::FunctionType, Type, Value, Block, operation::OperationBuilder, }, }; use crate::codegen::types::lox_value_type; /// Declare runtime functions needed for class operations pub fn declare_runtime_functions(context: &Context, module: &mut Module) { let location = Location::unknown(context); let lox_val = lox_value_type(context); // lox.create_class(name_ptr: !llvm.ptr, superclass: lox_val) -> lox_val let create_class_type = FunctionType::new( context, &[Type::parse(context, "!llvm.ptr").unwrap(), lox_val], &[lox_val], ); declare_external(module, context, "lox_create_class", create_class_type, location); // lox.instance_from_class(class: lox_val) -> lox_val let instance_type = FunctionType::new(context, &[lox_val], &[lox_val]); declare_external(module, context, "lox_instance_from_class", instance_type, location); // lox.get_property(instance: lox_val, name_ptr: !llvm.ptr) -> lox_val let get_prop_type = FunctionType::new( context, &[lox_val, Type::parse(context, "!llvm.ptr").unwrap()], &[lox_val], ); declare_external(module, context, "lox_get_property", get_prop_type, location); // lox.set_property(instance: lox_val, name_ptr: !llvm.ptr, value: lox_val) -> lox_val let set_prop_type = FunctionType::new( context, &[lox_val, Type::parse(context, "!llvm.ptr").unwrap(), lox_val], &[lox_val], ); declare_external(module, context, "lox_set_property", set_prop_type, location); // lox.bind_method(receiver: lox_val, method: lox_val) -> lox_val let bind_method_type = FunctionType::new(context, &[lox_val, lox_val], &[lox_val]); declare_external(module, context, "lox_bind_method", bind_method_type, location); } fn declare_external( module: &mut Module, context: &Context, name: &str, fn_type: FunctionType, location: Location, ) { module.body().append_operation(func::func( context, StringAttribute::new(context, name), TypeAttribute::new(fn_type.into()), Region::new(), &[], location, )); } }
Compiling Class Declarations
#![allow(unused)] fn main() { // src/codegen/generator.rs (additions) impl<'c> CodeGenerator<'c> { fn compile_class(&mut self, class: &ClassStmt) { let location = self.loc(class.location); // 1. Resolve superclass (if any) let superclass_val = if let Some(super_name) = &class.superclass { // Look up the superclass variable — it should be a class object self.compile_variable(&VariableExpr { location: class.location, name: super_name.clone(), }) } else { self.compile_nil() }; // 2. Create a global string constant for the class name let name_global = self.create_string_constant(&class.name); // 3. Call runtime: lox_create_class(name, superclass) let lox_val = lox_value_type(self.context); let create_class = func::call( self.context, FlatSymbolRefAttribute::new(self.context, "lox_create_class"), &[name_global, superclass_val], &[lox_val], location, ); if let Some(block) = &self.current_block { block.append_operation(create_class.clone()); } let class_val = create_class.result(0).unwrap().into(); // 4. Store each method on the class for method in &class.methods { self.compile_method(class_val, method); } // 5. Store the class object as a variable self.variables.insert(class.name.clone(), class_val); } fn compile_method(&mut self, class_val: Value<'c>, method: &FunctionStmt) { let location = self.loc(method.location); // Compile the method as a closure with two extra upvalues: // - upvalue[0] = this (will be bound at call time) // - upvalue[1] = super (the superclass, for super calls) // This is the same as compile_function, but with the extra upvalue slots. // For now, compile it as a regular function // (A full implementation would add the implicit upvalues) self.compile_function(method); // Then call a runtime function to attach the method to the class let method_val = self.variables.get(&method.name).copied().unwrap(); let name_global = self.create_string_constant(&method.name); let attach = func::call( self.context, FlatSymbolRefAttribute::new(self.context, "lox_set_method"), &[class_val, name_global, method_val], &[lox_value_type(self.context)], location, ); if let Some(block) = &self.current_block { block.append_operation(attach); } } } }
Compiling Property Access and Assignment
#![allow(unused)] fn main() { impl<'c> CodeGenerator<'c> { fn compile_get(&mut self, get: &GetExpr) -> Value<'c> { let location = self.loc(get.location); let object = self.compile_expression(&get.object); // Create a global string constant for the property name let name_global = self.create_string_constant(&get.name); // Call runtime: lox_get_property(instance, "name") let op = func::call( self.context, FlatSymbolRefAttribute::new(self.context, "lox_get_property"), &[object, name_global], &[lox_value_type(self.context)], location, ); if let Some(block) = &self.current_block { block.append_operation(op.clone()); } op.result(0).unwrap().into() } fn compile_set(&mut self, set: &SetExpr) -> Value<'c> { let location = self.loc(set.location); let object = self.compile_expression(&set.object); let value = self.compile_expression(&set.value); let name_global = self.create_string_constant(&set.name); // Call runtime: lox_set_property(instance, "name", value) let op = func::call( self.context, FlatSymbolRefAttribute::new(self.context, "lox_set_property"), &[object, name_global, value], &[lox_value_type(self.context)], location, ); if let Some(block) = &self.current_block { block.append_operation(op.clone()); } // set expressions return the assigned value (like assignment) op.result(0).unwrap().into() } } }
Compiling this and super
#![allow(unused)] fn main() { impl<'c> CodeGenerator<'c> { fn compile_this(&mut self, this: &ThisExpr) -> Value<'c> { // `this` is just a variable lookup — it's stored as an upvalue // by the method binding mechanism self.compile_variable(&VariableExpr { location: this.location, name: "this".to_string(), }) } fn compile_super(&mut self, super_expr: &SuperExpr) -> Value<'c> { let location = self.loc(super_expr.location); // `super.method` resolves to: // 1. Get the superclass from the implicit upvalue // 2. Look up the method on the superclass // 3. Bind it to `this` let super_class = self.compile_variable(&VariableExpr { location: super_expr.location, name: "super".to_string(), }); let method_name = self.create_string_constant(&super_expr.method); let this_val = self.compile_this(&ThisExpr { location: super_expr.location }); // Call runtime: lox_super_lookup(superclass, "method", this) let op = func::call( self.context, FlatSymbolRefAttribute::new(self.context, "lox_super_lookup"), &[super_class, method_name, this_val], &[lox_value_type(self.context)], location, ); if let Some(block) = &self.current_block { block.append_operation(op.clone()); } op.result(0).unwrap().into() } } }
What the Generated MLIR Looks Like
Given our doughnut example from the top:
module {
// Runtime function declarations
func.func @lox_create_class(!llvm.ptr, !llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)>
func.func @lox_instance_from_class(!llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)>
func.func @lox_get_property(!llvm.struct<(i8, i64)>, !llvm.ptr) -> !llvm.struct<(i8, i64)>
func.func @lox_set_property(!llvm.struct<(i8, i64)>, !llvm.ptr, !llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)>
func.func @lox_bind_method(!llvm.struct<(i8, i64)>, !llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)>
// Global string constants
llvm.mlir.global constant @str_0("Doughnut")
llvm.mlir.global constant @str_1("cook")
llvm.mlir.global constant @str_2("flavor")
llvm.mlir.global constant @str_3("FilledDoughnut")
llvm.mlir.global constant @str_4("custard")
// Doughnut.cook method
func.func @Doughnut_cook(%arg0: !llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)> {
// print "Frying " + flavor + " doughnut"
// ... (string concatenation via runtime calls)
func.return %nil : !llvm.struct<(i8, i64)>
}
// FilledDoughnut.cook method
func.func @FilledDoughnut_cook(%arg0: !llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)> {
// super.cook(flavor)
// print "Injecting " + flavor + " filling"
// ... (runtime calls)
func.return %nil : !llvm.struct<(i8, i64)>
}
// Top-level code
func.func @main() -> !llvm.struct<(i8, i64)> {
// Create Doughnut class
%doughnut = func.call @lox_create_class(@str_0, %nil) : (!llvm.ptr, !llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)>
// Create FilledDoughnut class (inherits from Doughnut)
%filled = func.call @lox_create_class(@str_3, %doughnut) : (!llvm.ptr, !llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)>
// var d = FilledDoughnut()
%d = func.call @lox_instance_from_class(%filled) : (!llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)>
// d.cook("custard")
%method = func.call @lox_get_property(%d, @str_1) : (!llvm.struct<(i8, i64)>, !llvm.ptr) -> !llvm.struct<(i8, i64)>
%custard = ... // string constant for "custard"
func.call @lox_call(%method, %custard) : (!llvm.struct<(i8, i64)>, !llvm.struct<(i8, i64)>) -> !llvm.struct<(i8, i64)>
func.return %nil : !llvm.struct<(i8, i64)>
}
}
The IR is verbose, but that's the point — it's an intermediate representation, not hand-written code. Each operation has clear semantics and the lowering passes can optimize it.
The C Runtime
The runtime functions are simple C — they operate on the same tagged union and heap we built in Parts 2–5.
// src/runtime/class_runtime.c
#include "runtime.h"
#include "gc.h"
#include <string.h>
// Create a new class object
LoxValue lox_create_class(const char* name, LoxValue superclass) {
ObjClass* klass = gc_allocate(sizeof(ObjClass));
klass->header.type = OBJ_CLASS;
klass->header.is_marked = false;
klass->name = strdup(name);
klass->methods = NULL; // empty hash map
klass->method_count = 0;
klass->superclass = IS_NIL(superclass) ? NULL : AS_CLASS(superclass);
return MAKE_OBJ(klass);
}
// Create an instance of a class
LoxValue lox_instance_from_class(LoxValue class_val) {
ObjClass* klass = AS_CLASS(class_val);
ObjInstance* instance = gc_allocate(sizeof(ObjInstance));
instance->header.type = OBJ_INSTANCE;
instance->header.is_marked = false;
instance->klass = klass;
instance->fields = NULL; // empty hash map
instance->field_count = 0;
return MAKE_OBJ(instance);
}
// Get a property on an instance
LoxValue lox_get_property(LoxValue instance_val, const char* name) {
ObjInstance* instance = AS_INSTANCE(instance_val);
// Check fields first (fields shadow methods)
for (int i = 0; i < instance->field_count; i++) {
if (strcmp(instance->fields[i].key, name) == 0) {
return instance->fields[i].value;
}
}
// Look up method on the class
ObjClass* klass = instance->klass;
while (klass != NULL) {
for (int i = 0; i < klass->method_count; i++) {
if (strcmp(klass->methods[i].key, name) == 0) {
// Bind the method to this instance
return lox_bind_method(instance_val, klass->methods[i].value);
}
}
klass = klass->superclass;
}
// Runtime error: undefined property
fprintf(stderr, "Undefined property '%s'.\n", name);
exit(1);
}
// Set a property on an instance
LoxValue lox_set_property(LoxValue instance_val, const char* name, LoxValue value) {
ObjInstance* instance = AS_INSTANCE(instance_val);
// Check if field already exists
for (int i = 0; i < instance->field_count; i++) {
if (strcmp(instance->fields[i].key, name) == 0) {
instance->fields[i].value = value;
return value;
}
}
// Add new field
int idx = instance->field_count++;
instance->fields = gc_reallocate(
instance->fields,
idx * sizeof(FieldEntry),
(idx + 1) * sizeof(FieldEntry)
);
instance->fields[idx].key = strdup(name);
instance->fields[idx].value = value;
return value;
}
// Bind a method to a receiver
LoxValue lox_bind_method(LoxValue receiver, LoxValue method) {
ObjBoundMethod* bound = gc_allocate(sizeof(ObjBoundMethod));
bound->header.type = OBJ_BOUND_METHOD;
bound->header.is_marked = false;
bound->receiver = AS_INSTANCE(receiver);
bound->method = method;
return MAKE_OBJ(bound);
}
Design Decisions and Trade-offs
Why Runtime Calls Instead of Pure MLIR?
You could emit pure MLIR for class operations — llvm.alloca for field storage, llvm.insertvalue/llvm.extractvalue for field access, etc. But:
| Approach | Pros | Cons |
|---|---|---|
| Runtime calls | Simple, correct, GC-aware | Can't optimize across boundary |
| Pure MLIR | Optimizable, no FFI overhead | Must teach MLIR about GC roots, field layout, dispatch |
For a tutorial, runtime calls are the right call. A production compiler would progressively move more into MLIR as it proves correctness. Start simple, optimize later.
Why No VTable?
VTables are an optimization for static dispatch. Lox's dispatch is dynamic — methods can be added at runtime, classes are first-class values. A hash map lookup per dispatch is the honest representation. If profiling shows it's a bottleneck, you add inline caches later.
Why Not Generic lox_call?
In the generated IR, method calls go through lox_get_property (returns a bound method) then lox_call (invokes the closure). Two runtime calls per method invocation. An optimization would be a single lox_invoke(instance, method_name, args) that combines both. We keep them separate for clarity — each function does one thing.
Full Update to the Expression Compiler
Adding the new expression types to the main compile_expression dispatch:
#![allow(unused)] fn main() { // src/codegen/generator.rs (updated match arm) fn compile_expression(&mut self, expr: &Expr) -> Value<'c> { match expr { Expr::Binary(b) => { let op = self.compile_binary(b); op.result(0).unwrap().into() } Expr::Unary(u) => { let op = self.compile_unary(u); op.result(0).unwrap().into() } Expr::Literal(l) => self.compile_literal(l), Expr::Grouping(g) => self.compile_expression(&g.expr), Expr::Variable(v) => self.compile_variable(v), Expr::Assign(a) => self.compile_assign(a), Expr::Call(c) => { let op = self.compile_call(c); op.result(0).unwrap().into() } Expr::Logical(l) => { let op = self.compile_logical(l); op.result(0).unwrap().into() } Expr::Get(g) => self.compile_get(g), Expr::Set(s) => self.compile_set(s), Expr::This(t) => self.compile_this(t), Expr::Super(s) => self.compile_super(s), } } }
Testing
Unit Tests for Method Lookup
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn method_lookup_walks_inheritance() { let mut base_class = ObjClass { header: ObjHeader::new(ObjType::Class), name: "Base".to_string(), methods: HashMap::new(), superclass: None, }; base_class.methods.insert("greet".to_string(), Value::Nil); let mut derived_class = ObjClass { header: ObjHeader::new(ObjType::Class), name: "Derived".to_string(), methods: HashMap::new(), superclass: Some(&base_class as *const ObjClass as *mut ObjClass), }; // Derived doesn't have "greet", but Base does assert!(derived_class.find_method("greet").is_some()); // Derived doesn't have "missing" and neither does Base assert!(derived_class.find_method("missing").is_none()); } #[test] fn fields_shadow_methods() { let mut class = ObjClass { header: ObjHeader::new(ObjType::Class), name: "Test".to_string(), methods: HashMap::new(), superclass: None, }; class.methods.insert("x".to_string(), Value::Number(42.0)); let mut instance = ObjInstance::new(&class as *const ObjClass as *mut ObjClass); // Set field "x" to a different value instance.set_property("x".to_string(), Value::Number(99.0)); // Field should shadow the method let result = instance.get_property("x").unwrap(); assert_eq!(result, Value::Number(99.0)); } } }
Summary
| Concept | How We Implemented It |
|---|---|
| Class declaration | Runtime call lox_create_class |
| Instance creation | Runtime call lox_instance_from_class |
| Property access | Runtime call lox_get_property (fields before methods) |
| Property assignment | Runtime call lox_set_property |
| Method binding | ObjBoundMethod wraps receiver + closure |
this | Implicit upvalue, filled when method is bound |
super | Implicit upvalue holding the superclass, resolved at compile time |
| Inheritance | Linked list of superclass pointers, walked during method lookup |
| GC tracing | Walk methods, superclass, fields, receiver, and bound method |
Classes tie together every system we've built: the GC heap, closures, upvalues, and MLIR code generation. There's no new fundamental mechanism — just new combinations of what already exists. That's how you know the architecture is right.
Next: The series is complete. The "Next Steps" from Part 1 are all covered. To go further, consider adding: inline caches for method dispatch, a custom Lox dialect (instead of using only arith/scf/func), or JIT compilation via ORCv2.