ADR 0015 — M13 G2–G3: dynamic dispatch and owning closures
- Status: Accepted (project owner, 2026-10-28, taking every recommendation in §5). The wording is the agent's; the owner may revise it. Not yet implemented: the decisions go into
type-system.md(§21, §36, and a new §37),grammar.md,lifetimes.md,intermediate-representation.mdandc-backend.mdbefore any code. - Scope: M13 G2 and G3 (
milestone-13.md). It closes OD-10 (dyn Interface) and OD-9b (owning, heap-allocated closures), which ADR 0012 deferred "only together withdyn". It amendstype-system.md§21.1 ("Interfaces are not types").
1. Context
- Interfaces are bounds only.
type-system.md§21.1: "there is nodyn Shapevalue in v0 (OD-10)". Generics are monomorphised (§21.2). A list of shapes of different types cannot be written. Probe atb9fa451:Vector<Box<dyn Shape>>isexpected '>', found identifier(N0100). - Interface methods are dispatchable. Every method has a receiver:
this,borrow thisorborrow mut this. Methods take no type parameters, and v0 has noSelftype (§21.1). So every method that takes a reference receiver could be called through a table of function pointers. A method that takesthisby value could not, since the caller does not know the value's size. - Closures are scoped. A capturing closure is a reference to an environment in its maker's block. So it cannot be returned, stored past its block, or sent to another thread (§36.2; ADR 0012 §5a). Probe: returning
(x: u64) => x + nasfn(u64) -> u64is N0299 ("this closure captures 'n', so it is a 'borrow fn'"). - Allocation is explicit and fallible.
Box.createreturnsResult<Box<T, A>, AllocError>(§18). The kernel profile allows allocation only through an explicit allocator (KernelHeap, C5). So an owning closure must not allocate behind the programmer's back. - No C-level UB from function pointers. ADR 0012 §3.6 generates an adapter instead of casting function pointer types. The same rule applies to tables of methods.
- Threads (G4) will need both features. A thread's body is an owning closure, sent to another thread. That ADR comes after this one. This one should not rule out marking a
dyntype as safe to send (§3.8).
2. Options
Dynamic dispatch
| A. Fat pointers (proposed) | B. A vtable in the object | C. Enums only | |
|---|---|---|---|
A dyn value is | { data, vtable } behind a reference or box | a thin pointer to an object whose first field is its vtable | no dyn: users write a closed enum and match |
| Works for existing types | yes (any T that implements I) | only types declared to carry a header | yes, but the set of types is closed |
| Cost | two words per pointer; one indirect call | one word; a header in every object | a branch per call |
| Precedent | Rust, Go's interface values | C++ | — (what Nova has today) |
A works with today's types and values, needs no layout change, and keeps borrow dyn I free (no allocation). B changes object layouts. C is the status quo. A is proposed.
Owning closures
D. move literals boxed as Box<dyn fn(…) -> R, A> (proposed) | E. Monomorphised closures with Fn bounds (OD-9a) | F. Garbage-collected or reference-counted closures | |
|---|---|---|---|
| Storage | one explicit, fallible Box allocation | none: the closure is a value of an anonymous type | implicit heap |
| Can be returned or stored in a field | yes | only through generics | yes |
| Call | indirect | direct, inlinable | indirect |
| Needs | A (dyn), a move literal | anonymous types, Fn interfaces, bound syntax | a runtime Nova does not have |
D follows from A with little new: a move literal's environment is an ordinary value, and the box holds it behind a dyn function type. E stays open as OD-9a, the faster path for generic code. F conflicts with Nova's memory model. D is proposed.
3. Proposed decision
dyn Itypes. For an interface instantiationI(Shape,Comparable<i32>),dyn Iis a type whose values are "some type that implementsI". It has no size known to the compiler, so it appears only behind a pointer:borrow dyn Iandborrow mut dyn I: references, scoped like every reference (§13);Box<dyn I, A>: an owning box, a move type that needs drop.
A
dyn Ianywhere else is a new error: a local, a field, a parameter, an array orVectorelement, or a type argument.Vector<Box<dyn I>>is allowed, since its elements are boxes.Dyn-compatible interfaces.
dyn Iis allowed when every method ofItakesborrow thisorborrow mut this. A method that takesthisby value makesIincompatible:dyn Iis a new error, which names that method. A generic interface is compatible once its type arguments are given.Coercions happen only where the expected type asks for them, never through inference:
borrow T→borrow dyn Iandborrow mut T→borrow mut dyn I, whenTimplementsI;Box<T, A>→Box<dyn I, A>(a move of the box, with no new allocation);- a
Tthat does not implementIis N0268, as for an unsatisfied bound.
There is no way back: v1 has no downcasting and no
is/asfromdyn ItoT.Calls.
x.area()on aborrow dyn Shape, aborrow mut dyn Shapeor aBox<dyn Shape>calls through the table. The receiver rules are those of a reference toT: aborrow mut thismethod needs mutable access. A call's effects are those declared on the interface method. Without a declaration, it maysleepandalloc, as for C function pointers and closures without a list (§32.1, §36.3). Animplmethod's inferred effects must fit what its interface declares (N0295).What
Tmay be. In v1,Tmust not be scoped: no references, no closures and no region parameters, so thedynvalue carries no loans other than the pointer's own. That keepslifetimes.mdunchanged:borrow dyn Icarries the loan of the reference it came from, andBox<dyn I>carries none. Adyn(r) Iholding scoped values is future work.Owning closures (OD-9b):
- Types.
dyn fn(T…) -> Randdyn mut fn(T…) -> Rare thedynforms of the closure types. Behind aBox, they own their environment:Box<dyn fn(u64) -> u64>is an owning closure. The existingborrow fn(…) -> Rkeeps its meaning and representation (§36.2). It is not rewritten asborrow dyn fn. - Literals.
move (x) => x + nis a move literal. It captures each named local by move, or by copy for a Copy type. Its value may appear only as the argument ofBox.createorBox<…, A>.createInwhere the expected result is aBox<dyn fn…, A>orBox<dyn mut fn…, A>. Anywhere else, it is a new error, until OD-9a gives move literals their own types. - Mutation. A body that changes a captured value makes the literal a
dyn mut fn, called through aborrow mutof the box. Adyn fnis called through a shared one. - Allocation.
Box.create(move (x) => x + n)returnsResult<Box<dyn fn(u64) -> u64>, AllocError>. Allocation stays explicit and fallible, and the kernel profile can use it withKernelHeap. - Captures. v1 allows only non-scoped captures, by the same rule as 5. Capturing a reference is a new error.
- Types.
C representation.
borrow dyn Iisstruct { void *data; const nvT_<T,I> *vt; }, two words.Box<dyn I, A>is the same pair with an owningdata.- The table is a
static conststruct per (T,I). It holds the drop glue, the size and alignment (for a user allocator'sdeallocatelayout), then one entry per method in declaration order. - Each entry is a generated adapter
R nvTA_…(void *self, A…), which convertsselftoT *and callsT's method. So no function pointer is ever called through a different type. - A call is
x.vt->m(x.data, args…). Dropping aBox<dyn I, A>callsvt->drop(data), then freesdatawithAand the table's size and alignment. - A boxed move literal's environment is a struct of its captures. Its table holds the body as the call entry and the environment's drop glue.
Room for threads (G4). The thread ADR may add marker bounds to
dyntypes (dyn fn() + Send). Nothing here depends on that, and nothing here prevents it.NIR and the interpreter. NIR gains a
dynpointer aggregate{ data, vtable }, a vtable constant per (T,I), and a call through a table entry. Coercion is building the pair. The borrow checker treatsborrow dyn Ias a reference, so it needs no new rule. The interpreter keeps a table of the implementing method per (T,I) and calls it.
4. Consequences
- Open sets of types become possible:
Vector<Box<dyn Shape>>, plugin-style registries, callbacks stored in structs, and functions that return closures (fn adder(n: u64) -> Box<dyn fn(u64) -> u64>). - A
dyncall is indirect and not inlined. Hot code can still use generics. No performance claim without a benchmark: adynagainst generic dispatch comparison joinsbenchmarks/. - The language gains one keyword in types (
dyn), one literal prefix (move) and one generated table per (T,I) actually coerced, so code size grows only with use. - Scoped
dynvalues (dyn(r) I), downcasting, and move literals outside a box remain open: OD-10a, OD-10b and OD-9a.
5. Questions for the owner (decided 2026-10-28: each as recommended)
- Fat pointers (A) for
dyn I, behind references andBoxonly? Recommended: yes. - Owning closures as boxed
moveliterals (D),Box<dyn fn(…) -> R>? Or wait for monomorphised closures (OD-9a)? Recommended: D now. It is what returning or storing a closure needs. OD-9a can follow for speed. - Syntax.
dyn Iandmove (x) => …, as in Rust? Or a Nova-specific spelling, such asany Shape(Swift) orinterface Shape? Recommended:dynandmove. They are short, widely known, and say what they mean. - v1 limits: no scoped
Tbehinddyn, and no scoped captures in move literals (§3.5, §3.6). Recommended: yes. They keep the borrow checker unchanged, anddyn(r) Ican follow once a use needs it. - No downcasting in v1. Recommended: yes. Closed sets of types are better served by enums and
match.
6. Implementation plan (once accepted)
Spec first (type-system §21 amended, §37 dyn, §36.5 move literals; grammar; IR; c-backend), then:
- G2a.
dyn Itypes, compatibility, coercions, calls throughborrow dyn I: checker, NIR, C, interpreter. - G2b.
Box<dyn I, A>: coercion fromBox<T, A>, drop through the table, user allocators. - G3. Move literals,
dyn fn/dyn mut fn, boxing, calls; the fuzzer generatingdyncalls and boxed closures.
Tests to add (none run yet):
- run-pass, compiled and interpreted:
- a heterogeneous
Vector<Box<dyn Shape>>; borrow mut dynmutation;- a generic interface;
- drop order through
Box<dyn I>; - a returned adder closure;
- a counter closure (
dyn mut fn); - a closure stored in a struct field;
- fault injection on
Box.create(move …).
- a heterogeneous
- check-fail:
dyn Iby value;- an incompatible interface;
- a
Tthat does not implementI; - scoped
T; - a move literal outside a box;
- a scoped capture;
- a
dyn mut fncalled through a shared reference; - a
dyncall in an atomic context without@effects().
- The M2 mutation suite extended with
dynreferences.