Methods and Vtables
How to lower method calls, interfaces, and virtual dispatch to SBBE IR.
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
- Object layout:
Counterhas one field,count(i32), at offset 0. Total size: 4 bytes. - Method signature: The frontend adds a hidden
selfparameter as the first argument.increment()becomes$Counter_increment(ptr). Theptris the address of theCounterinstance. - Field access through self: Inside the method body,
self.countisldl 0(load the self pointer) followed byldm i32(load the i32 at offset 0). - Name mangling: The frontend generates a unique function name like
$Counter_increment. The mangling scheme is the frontend’s choice. - Call site: Since the compiler knows the exact type, it emits
call $Counter_incrementdirectly. 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
- Object layout with vtable pointer: Every object that supports virtual dispatch has a
ptrat offset 0 pointing to its vtable. Instance fields follow. ForCircle: vtable_ptr at offset 0 (8 bytes), radius at offset 8 (8 bytes). Total: 16 bytes. - Vtable layout: The vtable is an array of
i32function indices. Slot 0 holds the index ofarea, slot 1 holds the index ofperimeter. The vtable is stored in static memory, shared by all instances of the same class. - 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
selfas the first argument callito dispatch
- Load the vtable pointer from offset 0 of the object (
- Why a vtable pointer in each object? Every instance of
Circleshares the same vtable, but the caller only has aShapepointer and doesn’t know the concrete type. The vtable pointer lets the runtime resolve to the correct implementation. - Why
i32function indices? Function indices are small integers. Usingi32instead ofptrhalves 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.
- Fat pointer layout: 16 bytes.
data_ptrat offset 0 (pointer to the actual object data),vtable_ptrat offset 8 (pointer to the vtable for this interface). - 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. - 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
- Static dispatch: Direct
callwith the receiver as the first parameter - Virtual dispatch: Load the vtable pointer from the object, index into it, then
calli - Interface dispatch: Fat pointer (data_ptr + vtable_ptr), vtable lookup, then
calli - Name mangling is the frontend’s job
- SBBE never sees classes, interfaces, or inheritance; it only sees pointers, loads, and indirect calls