Object-oriented method dispatch is a frontend concern. SBBE provides the building blocks (calli, memory operations, function indices) and the frontend uses them to implement whatever dispatch model the source language requires.

What we’re lowering

class Counter {
    var count: Int32 = 0
    func increment() { count += 1 }
    func get() -> Int32 { return count }
}

interface Shape {
    func area() -> Double
}

class Circle: Shape {
    var radius: Double
    func area() -> Double { return 3.14159 * radius * radius }
}

let c = Counter()
c.increment()        // static dispatch — resolved at compile time
c.increment()
c.get()              // returns 2

let s: Shape = Circle(radius: 5.0)
s.area()             // virtual dispatch — resolved at runtime via vtable

There are two fundamentally different dispatch mechanisms here. c.increment() is static dispatch: the compiler knows the exact function at compile time and emits a direct call. s.area() is virtual dispatch: the compiler only knows the interface, so it must look up the function at runtime through a vtable.

Static dispatch (non-virtual methods)

Pseudo-code

class Counter {
    var count: Int32 = 0
    func increment() { count += 1 }
    func get() -> Int32 { return count }
}

Step-by-step lowering

  1. Object layout: Counter has one field, count (i32), at offset 0. Total size: 4 bytes.
  2. Method signature: The frontend adds a hidden self parameter as the first argument. increment() becomes $Counter_increment(ptr). The ptr is the address of the Counter instance.
  3. Field access through self: Inside the method body, self.count is ldl 0 (load the self pointer) followed by ldm i32 (load the i32 at offset 0).
  4. Name mangling: The frontend generates a unique function name like $Counter_increment. The mangling scheme is the frontend’s choice.
  5. Call site: Since the compiler knows the exact type, it emits call $Counter_increment directly. No indirection, no vtable.

SBBE lowering

// Counter layout: { i32 count } at offset 0

func $Counter_increment(ptr) {
entry:
    ldl 0              // self
    dup                // keep self for the store
    ldm i32            // load self.count
    ldi 1
    add.s i32          // count + 1
    stm i32            // store back to self.count
    ret
}

func $Counter_get(ptr) -> i32 {
entry:
    ldl 0              // self
    ldm i32            // load self.count
    ret
}

func $main() -> i32 {
    var $c ptr

entry:
    // Allocate a Counter (4 bytes)
    ldi 4
    salloc
    str $c

    // Initialize count = 0
    ld $c
    ldi 0
    stm i32

    // c.increment() — static dispatch, direct call
    ld $c
    call $Counter_increment

    ld $c
    call $Counter_increment

    // c.get() — returns 2
    ld $c
    call $Counter_get
    ret
}

Virtual dispatch (vtables)

Pseudo-code

interface Shape {
    func area() -> Double         // vtable slot 0
    func perimeter() -> Double    // vtable slot 1
}

class Circle: Shape {
    var radius: Double            // offset 8 (after vtable ptr)
    func area() -> Double { return 3.14159 * radius * radius }
    func perimeter() -> Double { return 2 * 3.14159 * radius }
}

Step-by-step lowering

  1. Object layout with vtable pointer: Every object that supports virtual dispatch has a ptr at offset 0 pointing to its vtable. Instance fields follow. For Circle: vtable_ptr at offset 0 (8 bytes), radius at offset 8 (8 bytes). Total: 16 bytes.
  2. Vtable layout: The vtable is an array of i32 function indices. Slot 0 holds the index of area, slot 1 holds the index of perimeter. The vtable is stored in static memory, shared by all instances of the same class.
  3. Virtual call sequence: To call shape.area():
    • Load the vtable pointer from offset 0 of the object (ldm ptr)
    • Load the function index from slot 0 of the vtable (ldm i32)
    • Push self as the first argument
    • calli to dispatch
  4. Why a vtable pointer in each object? Every instance of Circle shares the same vtable, but the caller only has a Shape pointer and doesn’t know the concrete type. The vtable pointer lets the runtime resolve to the correct implementation.
  5. Why i32 function indices? Function indices are small integers. Using i32 instead of ptr halves the vtable size on 64-bit targets and makes vtable indexing cheaper.

SBBE lowering

// Circle.area implementation
func $Circle_area(ptr) -> f64 {
entry:
    ldl 0
    ldi 8
    add.s i64
    ldm f64            // load self.radius
    dup
    fmul f64           // radius * radius
    ldc f64 3.14159265358979
    fmul f64           // pi * r^2
    ret
}

// Virtual dispatch: call shape.area() through vtable
func $call_area(ptr) -> f64 {
entry:
    // Load vtable pointer from object (offset 0)
    ldl 0
    ldm ptr            // vtable_ptr

    // Load function index from vtable slot 0
    ldm i32            // vtable[0] = area function index

    // Call with self as first argument
    ldl 0              // push self
    swap               // put func_index on top
    calli 0            // call vtable[0](self)
    ret
}

Setting up the vtable

The vtable is typically stored in a static region of linear memory, initialized once at program startup:

var $circle_vtable ptr = 0

func $init_vtables() {
entry:
    // Allocate vtable (2 slots * 4 bytes each)
    ldi 8
    salloc
    str $circle_vtable

    // Slot 0: area -> index of $Circle_area
    ld $circle_vtable
    ldi 2              // function index of $Circle_area
    stm i32

    // Slot 1: perimeter -> index of $Circle_perimeter
    ld $circle_vtable
    ldi 4
    add.s i64
    ldi 3              // function index of $Circle_perimeter
    stm i32

    ret
}

Interface dispatch (fat pointers)

Pseudo-code

func totalArea(shapes: [Shape]) -> Double {
    var sum = 0.0
    for s in shapes {
        sum += s.area()  // s is an interface reference, not a concrete type
    }
    return sum
}

Step-by-step lowering

Some languages (Go, Rust trait objects) use “fat pointers” for interface dispatch: a (data_ptr, vtable_ptr) pair. This avoids storing the vtable pointer inside every object.

  1. Fat pointer layout: 16 bytes. data_ptr at offset 0 (pointer to the actual object data), vtable_ptr at offset 8 (pointer to the vtable for this interface).
  2. Call sequence: Load the vtable pointer from offset 8, index into it for the method slot, then load the data pointer from offset 0 and use it as self.
  3. Why fat pointers? Objects don’t need a vtable pointer field, so they are smaller. The cost is that every interface reference is 16 bytes instead of 8. This tradeoff favors languages where most values are concrete types and interface references are the exception.

SBBE lowering

// Fat pointer layout:
//   offset 0: ptr data_ptr
//   offset 8: ptr vtable_ptr

func $call_via_interface(ptr) -> f64 {
    // param 0 is a fat pointer to a Shape interface
entry:
    // Load vtable
    ldl 0
    ldi 8
    add.s i64
    ldm ptr            // vtable_ptr

    // Load function index from vtable slot 0 (area)
    ldm i32            // area function index

    // Load data pointer
    ldl 0
    ldm ptr            // data_ptr

    // Call: area(data_ptr)
    swap
    calli 0
    ret
}

Key takeaways