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:

ObjectWhat It HoldsAnalogy
ObjClassName, methods, superclassA blueprint
ObjInstanceClass pointer, field tableA house built from the blueprint
ObjBoundMethodReceiver + closureA 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"):

  1. Evaluate d → get the ObjInstance pointer
  2. Look up "cook" on the instance → get an ObjBoundMethod
  3. Call the bound method's closure with the provided arguments
  4. Inside the closure, this resolves 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:

  1. The superclass of the enclosing class (not the receiver's class)
  2. 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:

ApproachProsCons
Runtime callsSimple, correct, GC-awareCan't optimize across boundary
Pure MLIROptimizable, no FFI overheadMust 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

ConceptHow We Implemented It
Class declarationRuntime call lox_create_class
Instance creationRuntime call lox_instance_from_class
Property accessRuntime call lox_get_property (fields before methods)
Property assignmentRuntime call lox_set_property
Method bindingObjBoundMethod wraps receiver + closure
thisImplicit upvalue, filled when method is bound
superImplicit upvalue holding the superclass, resolved at compile time
InheritanceLinked list of superclass pointers, walked during method lookup
GC tracingWalk 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.