← Back to AILP Home

The dyn surface — fat pointers and dynamic dispatch

v0.54.6 — Comprehensive hardening: function parameter dispatch verified, struct field tests, borrow forms locked in (UTH-028..033).


1. Overview

A value of type dyn T is a fat pointer:

%struct.DynTraitObj = { ptr data, ptr vtable }

data points to the underlying concrete value (or an alias to it, for borrows). vtable is a global pointer to the method table for the concrete type that produced this dyn T. The vtable is named <TraitName>_vtable_<TypeName> and is emitted once per impl T:for:U block.

dyn T exists in three places: local variables, struct fields, and function parameters. Each has slightly different ABI but all three share the fat-ptr layout.


2. dyn T as a local variable

trait:Speak = {
    func:say = int32(Speak:self);
};

impl:Speak:for:Counter = {
    func:say = int32(Counter:self) { pass self.n; };
};

func:main = int32() {
    Counter:c = { n: 63i32 };
    dyn Speak:d = c;     // concrete → dyn coercion
    int32:r = d.say();   // dynamic dispatch through vtable
    exit r;              // 63
};

What happens at dyn Speak:d = c;:

  1. Compiler boxes c onto the local stack frame (a copy alloca).
  2. Builds a fat ptr {&boxed_c, &Speak_vtable_Counter}.
  3. Stores it in d.

Mutations through d do not affect the caller's c in this case because d owns its own copy. For caller-visible mutation use the borrow form $$m dyn T:d = c; — see Section 5.


3. dyn T as a function parameter

This is the most common use case for dynamic dispatch:

trait:Valued = { func:val = int32(int32:self); };
impl:Valued:for:int32 = { func:val = int32(int32:self) { pass self; }; };

func:get_val = int32(dyn Valued:item) {
    pass item.val();    // dispatches through vtable
};

func:main = int32() {
    int32:x = 10i32;
    int32:r = raw get_val(x);   // x coerced to dyn Valued at call site
    exit r;                     // 10
};

The caller boxes a copy and passes the fat ptr by value. The callee owns the fat ptr; mutations on item.data do not affect the caller.

Multiple dyn parameters

func:sum_vals = int32(dyn Valued:a, dyn Valued:b) {
    pass a.val() + b.val();
};

Each parameter gets its own independent fat pointer and vtable.

Mixed parameters

func:add_to_val = int32(int32:offset, dyn Valued:item) {
    pass offset + item.val();
};

Regular and dyn parameters can be freely mixed.

Nested forwarding

A function can forward a dyn T parameter to another function:

func:inner = int32(dyn Valued:item) { pass item.val(); };
func:outer = int32(dyn Valued:item) { pass raw inner(item); };

[!WARNING] Reserved keywords as function names: The name process is a reserved keyword (TOKEN_KW_PROCESS). Using it as a function name produces Cannot call non-function type 'process'. Other reserved keywords include result, process, atomic, channel, signal. Use descriptive names like run_dispatch, do_process, etc.


4. dyn T as a struct field

A struct field of type dyn T occupies 16 bytes (two pointers).

struct:Stage = { dyn Speak:performer; int32:volume; };

[!IMPORTANT] Struct literals require an explicit dyn T coercion before assignment. Direct concrete → dyn in a struct literal is not supported: nitpick Counter:c = { n: 63i32 }; dyn Speak:d = c; // step 1: explicit coercion Stage:s = { performer: d, volume: 10i32 }; // step 2: assign dyn to dyn field int32:r = s.performer.say(); // dispatches through vtable → 63

Heterogeneous assignment

Different concrete types can be assigned to the same dyn field type:

struct:Speaker = { int32:n; };
struct:Loud    = { int32:n; };

impl:Speak:for:Speaker = { func:say = int32(Speaker:self) { pass self.n; }; };
impl:Speak:for:Loud    = { func:say = int32(Loud:self) { pass self.n + 100i32; }; };

Speaker:s = { n: 42i32 };
Loud:l    = { n: 7i32 };

dyn Speak:d1 = s;
dyn Speak:d2 = l;

Stage:s1 = { performer: d1, volume: 10i32 };  // vtable = Speak_vtable_Speaker
Stage:s2 = { performer: d2, volume: 11i32 };  // vtable = Speak_vtable_Loud

s1.performer.say();   // → 42
s2.performer.say();   // → 107

Field-level borrows are not yet supported — borrow the whole struct instead.

dyn T in Arrays

You can create homogeneous arrays of fat pointers, allowing iteration over heterogeneous concrete objects:

// Using the same d1 and d2 from above
dyn Speak[2]:performers = [d1, d2];

int32:i = 0i32;
while (i < 2i32) {
    int32:r = performers[i].say();
    i = i + 1i32;
}

This array stores two 16-byte fat pointers sequentially. The array itself does not store the concrete structs, only the pointers to them.


5. Borrow forms

$$i dyn T — immutable borrow

func:read_count = int32($$i dyn Countable:item) {
    pass item.count();   // read-only dispatch
};

The call site builds a fat ptr whose data slot points to the caller's storage (not a boxed copy). The callee can read through the vtable but cannot mutate the caller's value.

$$m dyn T — mutable borrow

func:bump_it = int32($$m dyn Bumpable:b) {
    int32:r = b.bump();   // mutation propagates to caller
    pass r;
};

func:main = int32() {
    Counter:c = { n: 7i32 };
    int32:r = raw bump_it(c);
    exit c.n;   // 8 — mutation propagated through $$m dyn
};

For $$m dyn T:b, the call site builds a fat ptr whose data slot is the caller's storage address (not a boxed copy). Writes through self.n inside the impl land directly in the caller's variable.


6. Heterogeneous branching

int32:which = 1i32;
A:a  = { n: 5i32 };
B:bb = { n: 10i32 };
dyn Speak:d;
if (which == 0i32) {
    d = a;
} else {
    d = bb;
};
int32:r = d.say();   // 110 (B's impl adds 100)

d is statically typed dyn Speak but at run time can carry either a Speak_vtable_A or a Speak_vtable_B. Dispatch picks the right method based on whichever vtable d is currently holding.


7. Multi-method dispatch

A trait with multiple methods produces a vtable with one slot per method. Dispatch picks the slot by index at the call site:

trait:Double = {
    func:a = int32(Double:self);
    func:b = int32(Double:self);
};

vtable layout for impl:Double:for:Counter:

%Double_vtable_Counter = { ptr __dyn_Double_Counter_a,
                           ptr __dyn_Double_Counter_b };

d.a() loads index 0; d.b() loads index 1.


8. Static vs. dynamic dispatch

Aspect Static (impl method call) Dynamic (dyn T dispatch)
Resolved at Compile time Run time
Overhead Zero (direct call) Indirect call through vtable pointer
Inlining Yes (compiler can inline) No (opaque through vtable)
Heterogeneous collections No (type must be known) Yes (same dyn T for all impls)
Binary size One copy per concrete call One vtable + one dispatch thunk per impl

Use static dispatch for performance-critical hot paths. Use dyn T when you need heterogeneous containers, plugin architectures, or callback registries.


9. Known limitations

dyn Trait1 + Trait2 — not yet supported (UTH-029)

The parser does not handle the + combinator in type position for multi-bound dyn trait objects. A value that must satisfy two traits currently requires manual composition:

// Workaround: define a combined trait
trait:Combined = {
    func:pval = int32(Combined:self);
    func:cval = int32(Combined:self);
};

pick on dyn T — not supported (UTH-031)

pick is a value matcher (pattern matching on literal values), not a type matcher. Runtime type dispatch on dyn T to branch by concrete type requires future syntax (e.g. pick type(d) { ... }).

No implicit coercion in struct literals (UTH-030)

Struct fields of type dyn T require an explicit dyn T coercion before assignment in struct literals. Direct concrete → dyn in field position is not supported.

No K-semantics for vtable dispatch (UTH-032)

dyn T has no formal K-framework model. The fat pointer layout and vtable dispatch are implemented in the compiler and verified by runtime tests, but are not formally specified in k-semantics/nitpick.k.


10. Error reference

ARIA-043: No impl of trait in scope

error: ARIA-043 — no impl of trait 'T' is in scope for any concrete type

Cause: Coercion to dyn T requires at least one impl T:for:U block. Fix: Define the impl, or remove the dyn T declaration.

"Cannot call non-function type"

If you see this error on a function that accepts dyn T parameters, check that the function name is not a reserved keyword. Common reserved keywords: process, result, atomic, channel, signal.


See also