This page describes the current SSA builder and backend implementation. Its unversioned esp examples are compatibility snapshots, still accepted by the reader. Start with the new text walkthrough for esper 1 syntax and the roadmap for the library-first migration boundary.

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

Each example below is shown twice: the calls a frontend makes against the builder API in esper.h, and the text those calls produce. The two halves are checked against each other, so what the C tab builds is exactly what the Esper tab prints.

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 loaded via the self pointer 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.

Nothing in the builder API knows what a method is. esper_build_param(b, 0) is the receiver only because the frontend decided that parameter zero would be, and esper_build_call names the callee as a string because the frontend has already resolved it.

Esper lowering

/// Builds Counter.increment, whose receiver is parameter zero.
static void build_increment(EsperUnit *unit) {
  EsperTypeRef params[1] = {ESP_TYPE_PTR};

  EsperBuilder *b = esper_build_begin(unit, "Counter_increment", params, 1,
                                      NULL, 0);
  esper_build_bind(b, esper_build_block(b, "entry"));

  // count is the only field, so the receiver is already its address.
  EsperVal self = esper_build_param(b, 0);
  EsperVal count = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_I32, self);
  EsperVal one = esper_build_ldi(b, ESP_TYPE_I32, 1);
  EsperVal next = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32, count, one);
  esper_build_store(b, ESP_OP_STORE, ESP_TYPE_I32, self, next);

  esper_build_ret(b, NULL, 0);
  esper_build_end(b);
  esper_builder_free(b);
}

/// Builds Counter.get, the other half of the receiver convention.
static void build_get(EsperUnit *unit) {
  EsperTypeRef params[1] = {ESP_TYPE_PTR};
  EsperTypeRef rets[1] = {ESP_TYPE_I32};

  EsperBuilder *b = esper_build_begin(unit, "Counter_get", params, 1, rets, 1);
  esper_build_bind(b, esper_build_block(b, "entry"));

  EsperVal count = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_I32,
                                    esper_build_param(b, 0));
  esper_build_ret(b, &count, 1);

  esper_build_end(b);
  esper_builder_free(b);
}

static void esper_doc_build(EsperUnit *unit) {
  build_increment(unit);
  build_get(unit);

  EsperTypeRef rets[1] = {ESP_TYPE_I32};
  EsperBuilder *b = esper_build_begin(unit, "main", NULL, 0, rets, 1);
  esper_build_bind(b, esper_build_block(b, "entry"));

  // One Counter on the stack, its single field zeroed.
  EsperVal size = esper_build_ldi(b, ESP_TYPE_I32, 4);
  EsperVal c = esper_build_alloc(b, ESP_OP_SALLOC, size);
  EsperVal zero = esper_build_ldi(b, ESP_TYPE_I32, 0);
  esper_build_store(b, ESP_OP_STORE, ESP_TYPE_I32, c, zero);

  // The receiver is an ordinary first argument, so the calls are direct.
  EsperVal recv[1] = {c};
  esper_build_call(b, ESP_TYPE_VOID, "Counter_increment", recv, 1);
  esper_build_call(b, ESP_TYPE_VOID, "Counter_increment", recv, 1);

  EsperVal count = esper_build_call(b, ESP_TYPE_I32, "Counter_get", recv, 1);
  esper_build_ret(b, &count, 1);

  if (!esper_build_end(b))
    fprintf(stderr, "main: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}
func $Counter_increment(%0 ptr) {
entry:
  %1 = load i32 %0
  %2 = ldi i32 1
  %3 = add i32 %1, %2
  store i32 %0, %3
  ret
}

func $Counter_get(%0 ptr) -> i32 {
entry:
  %1 = load i32 %0
  ret i32 %1
}

func $main() -> i32 {
entry:
  %0 = ldi i32 4
  %1 = salloc %0
  %2 = ldi i32 0
  store i32 %1, %2
  call $Counter_increment, %1
  call $Counter_increment, %1
  %6 = call i32 $Counter_get, %1
  ret i32 %6
}

The gap in $main between %2 and %6 is not an error. A void instruction still occupies an SSA id, so the store and the two calls consume %3 through %5. A frontend that numbers ids itself instead of using the value the builder hands back will get this wrong.

esper_build_call resolves its callee by name at the point of the call, so Counter_increment has to exist before main is built. esper_func_declare is the way out when that order cannot be arranged, and is what mutual recursion needs.

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 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 (load ptr)
    • Load the function index from slot 0 of the vtable (load i32)
    • Pass self as the first argument
    • call.ind 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 does not 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.

Declaring the call shape

A direct call reads its arity and its types off the callee. An indirect call has only an SSA value, so there is nothing to read them off, and the shape has to be stated somewhere else. That somewhere is the unit’s signature table.

esper_unit_add_signature records one shape and returns the index that names it. esper_build_call_indirect takes that index and checks the argument count against it, which is the reason the table exists. The table deduplicates, so a frontend may call it once per call site without the table growing once per call site: every (ptr) -> f64 method in the program shares one index.

EsperTypeRef params[1] = {ESP_TYPE_PTR};
EsperTypeRef rets[1] = {ESP_TYPE_F64};
uint32_t shape_sig = esper_unit_add_signature(unit, params, 1, rets, 1);
sig (ptr) -> f64

The index is positional: sig lines print in table order, and call.ind f64 %2, 0, %0 names the first of them. A call site that uses a hand-written number rather than the value esper_unit_add_signature returned will be checked against the wrong shape as soon as a second signature appears.

Esper lowering

/// Builds Circle.area, which reads the radius past the vtable pointer.
static void build_circle_area(EsperUnit *unit, uint32_t pi) {
  EsperTypeRef params[1] = {ESP_TYPE_PTR};
  EsperTypeRef rets[1] = {ESP_TYPE_F64};

  EsperBuilder *b = esper_build_begin(unit, "Circle_area", params, 1, rets, 1);
  esper_build_bind(b, esper_build_block(b, "entry"));

  // radius sits at offset 8, after the vtable pointer.
  EsperVal self = esper_build_param(b, 0);
  EsperVal off = esper_build_ldi(b, ESP_TYPE_I32, 8);
  EsperVal addr = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I64, self, off);
  EsperVal radius = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_F64, addr);

  EsperVal sq = esper_build_binary(b, ESP_OP_FMUL, ESP_TYPE_F64, radius, radius);
  EsperVal k = esper_build_ldc(b, ESP_TYPE_F64, pi);
  EsperVal area = esper_build_binary(b, ESP_OP_FMUL, ESP_TYPE_F64, sq, k);
  esper_build_ret(b, &area, 1);

  esper_build_end(b);
  esper_builder_free(b);
}

static void esper_doc_build(EsperUnit *unit) {
  uint32_t pi = esper_unit_add_const_float(unit, ESP_TYPE_F64, 3.14159);
  build_circle_area(unit, pi);

  // Every Shape method has this shape, so one signature names them all.
  EsperTypeRef sig_params[1] = {ESP_TYPE_PTR};
  EsperTypeRef sig_rets[1] = {ESP_TYPE_F64};
  uint32_t shape_sig = esper_unit_add_signature(unit, sig_params, 1, sig_rets, 1);

  EsperTypeRef params[1] = {ESP_TYPE_PTR};
  EsperTypeRef rets[1] = {ESP_TYPE_F64};

  EsperBuilder *b = esper_build_begin(unit, "call_area", params, 1, rets, 1);
  esper_build_bind(b, esper_build_block(b, "entry"));

  // vtable pointer at offset 0, then slot 0 of the vtable.
  EsperVal self = esper_build_param(b, 0);
  EsperVal vtable = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_PTR, self);
  EsperVal target = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_I32, vtable);

  EsperVal args[1] = {self};
  EsperVal area = esper_build_call_indirect(b, ESP_TYPE_F64, target, shape_sig,
                                            args, 1);
  esper_build_ret(b, &area, 1);

  if (!esper_build_end(b))
    fprintf(stderr, "call_area: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}
const c0 f64 = 3.14159

sig (ptr) -> f64

func $Circle_area(%0 ptr) -> f64 {
entry:
  %1 = ldi i32 8
  %2 = add i64 %0, %1
  %3 = load f64 %2
  %4 = fmul f64 %3, %3
  %5 = ldc f64 c0
  %6 = fmul f64 %4, %5
  ret f64 %6
}

func $call_area(%0 ptr) -> f64 {
entry:
  %1 = load ptr %0
  %2 = load i32 %1
  %3 = call.ind f64 %2, 0, %0
  ret f64 %3
}

$call_area names neither Circle nor Circle_area. It is two loads and a call, and it would dispatch to any function whose shape matches sig (ptr) -> f64. That is what makes it virtual.

A float does not fit in a 32-bit immediate, so 3.14159 goes into the constant table and ldc names it. esper_unit_add_const_float returns the index, which the printed form spells c0.

Setting up the vtable

The table has to exist before the first dispatch. A global holds its address, and a startup function fills it in.

static void esper_doc_build(EsperUnit *unit) {
  // The slot the vtable pointer lives in. It is extern because the
  // storage belongs to the runtime the output links against.
  uint32_t vt = esper_unit_add_global_extern(unit, "circle_vtable",
                                             ESP_TYPE_PTR, true);

  EsperBuilder *b = esper_build_begin(unit, "init_vtables", NULL, 0, NULL, 0);
  esper_build_bind(b, esper_build_block(b, "entry"));

  // Two slots of four bytes, on the heap so the table outlives this call.
  EsperVal size = esper_build_ldi(b, ESP_TYPE_I32, 8);
  EsperVal table = esper_build_alloc(b, ESP_OP_HALLOC, size);

  // Slot 0 is area, slot 1 is perimeter, each the index of a function.
  EsperVal area = esper_build_ldi(b, ESP_TYPE_I32, 2);
  esper_build_store(b, ESP_OP_STORE, ESP_TYPE_I32, table, area);

  EsperVal four = esper_build_ldi(b, ESP_TYPE_I32, 4);
  EsperVal slot1 = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I64, table, four);
  EsperVal perimeter = esper_build_ldi(b, ESP_TYPE_I32, 3);
  esper_build_store(b, ESP_OP_STORE, ESP_TYPE_I32, slot1, perimeter);

  esper_build_global_set(b, ESP_TYPE_PTR, vt, table);
  esper_build_ret(b, NULL, 0);

  if (!esper_build_end(b))
    fprintf(stderr, "init_vtables: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}
global mut extern @circle_vtable ptr

func $init_vtables() {
entry:
  %0 = ldi i32 8
  %1 = halloc %0
  %2 = ldi i32 2
  store i32 %1, %2
  %4 = ldi i32 4
  %5 = add i64 %1, %4
  %6 = ldi i32 3
  store i32 %5, %6
  gset ptr @circle_vtable, %1
  ret
}

The allocation is halloc and not salloc because a stack allocation is released when $init_vtables returns, and the table has to outlive it. esper_build_alloc takes the opcode as an argument for exactly this choice; ESP_OP_ALLOC is the third option, which leaves the decision to the escape analysis pass.

The global is extern, so this unit declares the slot and something it links against provides the storage. That is what a vtable registry usually looks like: the runtime owns the table of tables and the compiled unit fills in its own entry.

A vtable that is laid out rather than filled

The form above builds the table every time the program starts, which is what a registry owned by the runtime needs. A vtable whose contents are known at compile time does not need any of it. It can be an aggregate constant, built with esper_unit_add_array and esper_const_agg_begin and attached to a global as its initializer, and gaddr gives the dispatch the ptr it adds an offset to.

static void esper_doc_build(EsperUnit *unit) {
  // Two slots, each holding the index of a function.
  uint32_t vt = esper_unit_add_array(unit, "CircleVTable", ESP_TYPE_I32, 2);

  EsperConstAgg *ca = esper_const_agg_begin(unit, esper_tref_agg(vt));
  esper_const_agg_int(ca, 2);
  esper_const_agg_int(ca, 3);
  uint32_t table = esper_const_agg_end(ca);

  // The table is the global's initializer, so it is laid out in the
  // output rather than written by a startup function.
  uint32_t g = esper_unit_add_global(unit, "circle_vtable",
                                     esper_tref_agg(vt), false,
                                     table);

  EsperTypeRef ret = ESP_TYPE_I32;
  EsperBuilder *b = esper_build_begin(unit, "area_of", NULL, 0, &ret, 1);
  esper_build_bind(b, esper_build_block(b, "entry"));

  // The address of the table, which is what indexing it needs. Slot 1
  // is perimeter, four bytes along.
  EsperVal base = esper_build_global_addr(b, g);
  EsperVal four = esper_build_ldi(b, ESP_TYPE_I32, 4);
  EsperVal slot = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I64, base, four);
  EsperVal fn = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_I32, slot);
  esper_build_ret(b, &fn, 1);

  if (!esper_build_end(b))
    fprintf(stderr, "area_of: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}
type CircleVTable = array i32[2]

const c0 CircleVTable = [2, 3]

global @circle_vtable CircleVTable = c0

func $area_of() -> i32 {
entry:
  %0 = gaddr ptr @circle_vtable
  %1 = ldi i32 4
  %2 = add i64 %0, %1
  %3 = load i32 %2
  ret i32 %3
}

gaddr yields the address of the global’s storage, not the value in it: a gget of an array-typed global copies the array, and the copy is not where the table lives. The global needs no mut here, since nothing writes through the address.

Which form to choose is a question about who owns the table. A vtable the compiled unit knows in full is the constant one, and it costs nothing at startup. A registry the runtime populates, or a table whose entries are only known once every unit has linked, is the halloc form above.

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 do not 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.

Esper lowering

static void esper_doc_build(EsperUnit *unit) {
  EsperTypeRef sig_params[1] = {ESP_TYPE_PTR};
  EsperTypeRef sig_rets[1] = {ESP_TYPE_F64};
  uint32_t shape_sig = esper_unit_add_signature(unit, sig_params, 1, sig_rets, 1);

  EsperTypeRef params[1] = {ESP_TYPE_PTR};
  EsperTypeRef rets[1] = {ESP_TYPE_F64};

  EsperBuilder *b = esper_build_begin(unit, "call_via_interface", params, 1,
                                      rets, 1);
  esper_build_bind(b, esper_build_block(b, "entry"));

  // The vtable half of the fat pointer sits at offset 8.
  EsperVal fat = esper_build_param(b, 0);
  EsperVal off = esper_build_ldi(b, ESP_TYPE_I32, 8);
  EsperVal slot = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I64, fat, off);
  EsperVal vtable = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_PTR, slot);
  EsperVal target = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_I32, vtable);

  // The data half is the receiver the method expects.
  EsperVal data = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_PTR, fat);
  EsperVal args[1] = {data};
  EsperVal area = esper_build_call_indirect(b, ESP_TYPE_F64, target, shape_sig,
                                            args, 1);
  esper_build_ret(b, &area, 1);

  if (!esper_build_end(b))
    fprintf(stderr, "call_via_interface: %s\n", esper_builder_last_error(b));

  esper_builder_free(b);
}
sig (ptr) -> f64

func $call_via_interface(%0 ptr) -> f64 {
entry:
  %1 = ldi i32 8
  %2 = add i64 %0, %1
  %3 = load ptr %2
  %4 = load i32 %3
  %5 = load ptr %0
  %6 = call.ind f64 %4, 0, %5
  ret f64 %6
}

The signature is the same one the embedded-vtable version used. Where the vtable pointer lives is a layout decision the frontend makes; the call shape is a property of the method, and the two are independent.

Key takeaways