Aspect Objects
Status — accepted. Depends on RFC-0060 (Aspect Impl Coherence,
4-implemented). Specifiesdyn Aspectas the mechanism for runtime polymorphism: values whose concrete type is erased at compile time and dispatch happens through a vtable. Complements RFC-0037 (return-positionimpl Aspect), which provides compile-time opaque types.
Split, 2026-08-25. Originally written entirely in terms of RFC-0063's
@[r]allocator-handle syntax, which was2-acceptedat draft time and is still not integrated or implemented today — checked directly againstmetel-frontend/src/grammar.pestondevelop: neither@[allocator-handle syntax nor thedynkeyword exists in the grammar, and there's novtablemachinery anywhere inmetel-core. That made the whole RFC read as blocked on RFC-0063. It wasn't, for everything except the explicitly allocator-tagged form: the interpreter already has a working implicit heap (Rc<RefCell<Value>>, used today byArray/Reference/MutReferencewith no@[r]annotation anywhere) that the representation below already reuses. The explicit@[r] dyn Aspectregion-tagged form is split out to RFC-0141 (Aspect Objects: Explicit Allocator Placement), which depends on this RFC and on RFC-0063. Nothing about the design below changed in the split — every section already describeddyn Aspectagainst the interpreter's existing implicit allocation; only the region-tagged extension moved to its own document, cleanly, since it needed a real, separate dependency this RFC's own core doesn't share.
Remaining Unresolved Questions answered and confirmed, 2026-08-25. UQ1/UQ2 (multi-aspect bounds, including
dyn Aspect + Send): resolved to at most one method-bearing aspect plus any number of marker aspects — see §9. UQ4 (vtable caching): resolved for the current interpreter, piggybacking on RFC-0060's already-4-implementedcoherence pass — see §9. Both were proposed and flagged as unreviewed when first written, then confirmed the same day (see §9's own alternatives analysis for UQ1 — accepting general multi-aspect bounds unconditionally was evaluated and rejected on cost, not left unconsidered).
Drop's object-safety exception removed, 2026-08-27.Drop::dropnow takesself: &var Self(RFC-0071), satisfying rule 1 by the ordinary case — no exception needed. See §3.
Status — integrated (2026-08-30). Retroactive: dyn Aspect syntax, object safety, representation, coercion, dispatch, and List
were spec'd into reference/spec/declarations.md and implemented (metel-core#865/#863/#864, all closed 2026-08-28), but RFC-0008 was never moved past 2-accepted. Adding coverage.spec links for sections 1-8 and running it through the lifecycle.
Status — implemented (2026-08-30). Retroactive: dyn Aspect is fully implemented and fixture-covered (metel-core#837/#865/#863/#864, all closed 2026-08-28); spec rules in reference/spec/declarations.md carry citing fixtures for every dyn-aspect legality/dynamics anchor. RFC had been stranded at 2-accepted.
Summary
Static dispatch (generics + monomorphisation) requires the concrete type to be known
at the call site. Aspect objects — written dyn Aspect — provide runtime
polymorphism: the concrete type is erased; all interaction goes through a vtable
generated by the compiler.
Aspect objects enable:
- Heterogeneous collections:
List<dyn Shape>holds any shape, regardless of concrete type. - Fully open extension points: a plugin API accepts any type implementing the aspect without enumerating them.
- Returns of different concrete types from branches: a function can return
dyn Aspectwith different concrete types on different paths.
dyn Aspect is the complement of impl Aspect: use impl Aspect when the concrete
type is fixed per call site (compile-time, zero overhead); use dyn Aspect when the
concrete type varies at runtime (runtime dispatch, allocation required — implicit
today; RFC-0141 adds explicit allocator control).
1. Syntax
An aspect object type is written dyn Aspect:
dyn Display
dyn Callable<i64, i64> // see note — Callable is not implemented; deferred to RFC-0161
dyn Region
dyn Callable/ theCallable<A, B>aspect are not implemented (2026-09-01). Specified here and in RFC-0061 §7.1, never built. Deferred in full to RFC-0161 (Callable Object Contract), v0.13.1, which also owns its object-safety story (the line in "Standard library object-safety" below is a reservation, not a shipped fact). Thedyn <Aspect>syntax itself is unchanged — in v0.13.0 there is simply no stdlibCallableaspect, sodyn Callable<…>is an unknown-aspect error unless the program declares its own;Callableis not reserved. Every otherdyn Aspectin this RFC is unaffected.
dyn Aspect is an unsized type — it has no compile-time size. It must appear behind
a pointer. The forms available today:
dyn Display // owned aspect object, implicit placement
&dyn Display // shared reference to an aspect object
&var dyn Display // exclusive reference to an aspect object
(RFC-0141 adds @[r] dyn Display — the same owned form, with an explicit allocator
named — once RFC-0063 exists to give r something to name.)
A value of concrete type T where T: Aspect may be coerced to dyn Aspect:
let shape: dyn Shape := Circle { radius = 5.0 };
let shape2: dyn Shape := Rectangle { w = 3.0, h = 4.0 };
2. Representation
A dyn Aspect is a fat pointer: a pair of pointers stored on the stack or
inline:
- Data pointer — points to the concrete value. Today this is wherever the
interpreter already places a value that has to outlive its stack frame or be
referenced heterogeneously —
Rc<RefCell<Value>>(metel-interpreter/src/evaluator/mod.rs), the same mechanismArray/Reference/MutReferencealready use with no allocator annotation. RFC-0141 lets this instead target an explicit region. - Vtable pointer — points to a compiler-generated vtable for the
(concrete type, aspect)pair.
The vtable contains:
- A function pointer for each method declared by the aspect.
- The size and alignment of the concrete type (for allocation and drop).
- A pointer to the
Dropdestructor if the concrete type implementsDrop.
Fat pointers have the size of two pointers. They are not Copy — they follow the
ownership rules of their inner type.
There's an existing fat-pointer pattern in the interpreter already, for an unrelated
purpose: FieldReference { root: Rc<RefCell<Value>>, path: Vec<PathSegment> }
(RFC-0045) — a root-pointer-plus-metadata pair the runtime already builds values
around, precedent for the shape above.
Vtable generation reuses RFC-0060's coherence output (§9 UQ4, resolved
2026-08-25). The coherence pass (metel-frontend/src/coherence.rs,
4-implemented) already builds by_aspect: HashMap<SymbolId, Vec<CollectedImpl>>,
and each CollectedImpl already carries method_names and the target type's
canonical key. A vtable for (T, Aspect) is generated from the matching
already-coherence-checked CollectedImpl, not discovered separately.
3. Object Safety — the receiver rule
Not every aspect can be used as dyn Aspect. An aspect is object-safe if all
its methods satisfy the rules in this section and §3a/§3b.
Receiver rule: the method's first parameter must be self: &Self,
self: &var Self, or self: @[r] Self for some region r (this third form is
dormant until RFC-0141's syntax exists to write it — no method can match it
today, which narrows what's checked, not what's correct). A bare by-move
receiver (self: Self) is not object-safe: moving a value requires knowing its
size at compile time, but dyn Aspect is unsized. Methods with Self in any
other position — return type, non-receiver parameters — are also not object-safe.
An aspect that violates this rule (or §3a/§3b) may be used with impl Aspect (static dispatch)
but not as dyn Aspect. The compiler reports an error if a non-object-safe aspect
appears in dyn position:
error: aspect Clone is not object-safe
reason: Clone::clone returns Self
use impl Clone (static dispatch) instead
Standard library object-safety:
Display— object-safe (to_stringtakes&Self, returnsString)Callable<A, B>— (not implemented; no stdlibCallableaspect exists — deferred to RFC-0161, which owns its object-safety story) would be object-safeDrop— object-safe (droptakesself: &var Self, RFC-0071). The vtable's drop function pointer (§2/§5) is separate per-type metadata everydyn Aspectvtable carries, unrelated to object safety.Clone— not object-safe (clonereturnsSelf)Deref— not object-safe (associated typeTargetin method signature)Send,Sync— marker aspects with no methods; object-safe but rarely used asdyn
Object safety is a purely static, per-aspect check with no dependency on allocation strategy — this section is unaffected by the split into RFC-0141.
3a. Object Safety — no generic type parameters on methods
No generic type parameters on methods: a method with its own type parameters
(fun map<U>(...)) cannot be dispatched through a vtable because the vtable
entry would need to be generated per U. Such methods are excluded from the
vtable; the aspect may still be object-safe if the non-generic methods are
sufficient — including when the generic method is the aspect's only method, the
same way a zero-method marker aspect is object-safe.
3b. Object Safety — no associated types in method signatures
No associated types in method signatures: associated types (e.g.
Deref::Target) that appear in method signatures make the vtable entry
type-depend on the concrete impl. Aspects with associated types are object-safe
only if no method signature references the associated type.
4. Method Dispatch
Calling a method on dyn Aspect dispatches through the vtable:
fun print_any(x: &dyn Display) {
x.to_string(); // vtable call — resolved at runtime
}
let shape: dyn Shape := Circle { radius = 5.0 };
print_any(&shape);
The compiler generates a vtable for every (T, Aspect) pair where:
TimplementsAspect, ANDTis coerced todyn Aspectsomewhere in the program.
Vtable entries are function pointers; dispatch is an indirect call through the vtable pointer in the fat pointer. This is the same cost as a virtual call in C++ or a method call through an interface in Go.
Vtable generation and the dispatch call don't reference the data pointer's target at all — a vtable is static, compiler-generated data. This section is unaffected by the split into RFC-0141.
5. Ownership and Drop
An owned dyn Aspect owns the concrete value. When the fat pointer is dropped, the
runtime:
- Calls the concrete type's
Dropdestructor via the vtable's drop pointer, if present. - Releases the underlying storage — today, however the interpreter already reclaims
an
Rc<RefCell<Value>>with no remaining owners (RFC-0141 changes this step to deallocating from an explicit region instead; nothing about when drop fires changes).
Coercing a value to dyn Aspect is a checked boundary, added 2026-08-27 (RFC-0137
§5, Open Question 8). Every dyn Aspect fat pointer for a Drop-implementing
concrete type carries the drop-pointer above regardless of which aspect it's
principally coerced to — but if RFC-0137 (Nominal Types as Branded Rows, 1-under- review) lands, that concrete type may have a narrower-than-declared row at the
coercion site (a residual, produced by partial move). Erasure discards that row
information; nothing here re-derives it once the value is behind a fat pointer. The
coercion site itself must therefore verify the value's current row satisfies the
concrete type's Drop impl's required field set (RFC-0137 §5 — since its 2026-08-28
amendment, the residual row declared on the drop method's receiver) before erasing
it, rejecting the coercion otherwise — the same check RFC-0137 §4's function-call
boundary already performs, applied here as one more site where the concrete type and its
row are both still statically known. Not yet a live concern: RFC-0137's own narrowing
mechanism isn't implemented, so no residual can reach a coercion site today regardless.
This requires the concrete type's size and drop function to be in the vtable. The compiler generates these entries for every coercion site.
Ownership and move timing follow whatever RFC-0071 already governs for an ordinary
owned struct value today (3-integrated, move-check implemented behind
--move-check) — moved, not Copy, dropped when the owning binding goes out of
scope.
&dyn Aspect and &var dyn Aspect are borrowed fat pointers. They do not own the
value and do not drop it.
6. Coercion
A concrete value is coerced to an aspect object at the binding site:
let x: dyn Display := 42; // i64 coerced to dyn Display
let y: dyn Display := "hello"; // String coerced to dyn Display
A reference to a concrete value is coerced to a borrowed aspect object:
let n: i64 := 42;
let x: &dyn Display := &n;
The coercion is implicit when the target type is dyn Aspect and the source type
implements the aspect. No explicit cast is required.
7. Heterogeneous Collections
The primary use case for aspect objects is heterogeneous collections — a list of values with different concrete types, all satisfying a common aspect:
let shapes: List<dyn Shape> = List::new();
shapes.push(Circle { radius = 5.0 });
shapes.push(Rectangle { w = 3.0, h = 4.0 });
for shape in shapes {
println(shape.area()); // dispatched through vtable
}
Each element of the list is a fat pointer to a different concrete type. The list is
homogeneous at the pointer level (dyn Shape) and heterogeneous at the value level.
List<T> itself has no dependency on RFC-0063 of its own, so this is, if anything,
the easiest section to de-risk from the region-handle cluster rather than the
hardest.
8. Aspect Objects and impl Aspect
impl Aspect (RFC-0035, RFC-0037) and dyn Aspect are complementary:
impl Aspect | dyn Aspect | |
|---|---|---|
| Concrete type known at | Compile time | Runtime |
| Dispatch | Static (monomorphised) | Dynamic (vtable) |
| Allocation | None (stack or inline) | Required (behind pointer — implicit today; RFC-0141 adds explicit control) |
| Multiple types in one container | No | Yes |
| Overhead | Zero | Indirect call + pointer size |
A function accepting impl Aspect is monomorphised per caller type; a function
accepting &dyn Aspect has a single compiled form that dispatches at runtime.
9. Unresolved Questions
-
Multi-aspect bounds. Whetherdyn Aspect1 + Aspect2is supported — a fat pointer to a value implementing both aspects — is deferred. The vtable would need to contain entries for both aspects. The syntax and vtable layout for multi-aspect objects is non-trivial.Resolved and confirmed 2026-08-25. Supported in exactly one shape: at most one method-bearing ("principal") aspect, plus any number of marker aspects, written
dyn Aspect + Marker1 + Marker2 + .... A marker aspect is any object-safe aspect declaring zero methods — mechanically checkable (§3 already calls outSend/Syncas examples; the rule generalizes to any user-defined zero-method aspect, not a hardcoded list).Why this shape and not general
Aspect1 + Aspect2: two method-bearing aspects would need either two independent vtables or one jointly-laid-out vtable — real additional complexity, not a detail this resolution glosses over. A marker aspect contributes zero vtable entries, so the restriction avoids that complexity by construction: exactly one vtable is ever generated (the principal aspect's, per §2/§4, unchanged), and each marker bound is a purely static check at the coercion site — same mechanism as object-safety checking, nothing added to the runtime representation. Consequences that follow directly:dyn Aspect + Markeris representationally identical to plaindyn Aspect— same fat pointer, same vtable — so it widens implicitly todyn Aspectwherever needed (dropping a marker guarantee is always safe).+is order-independent:dyn Display + Sendanddyn Send + Displayname the same type.- Two method-bearing aspects on one
dyntype (dyn Display + Debug) stays unsupported — decided against, not left pending. An aspect author needing both today declares a new aspect whose methods cover what's needed and implements it directly (real duplication, no delegation to the individualDisplay/Debugimpls); ergonomic composition sugar for that is RFC-0104 (Multi-Aspect Extend Blocks with Shared Bodies,0-draft), a separate, already-tracked question this resolution doesn't reopen.
Cost of accepting general multi-aspect bounds unconditionally, evaluated and rejected 2026-08-25. A fat pointer today is 2 words (data + one vtable, §2). Calling methods from two method-bearing aspects through one pointer needs either a second vtable pointer or a vtable that covers both aspects at once — no third option. Two implementation strategies exist, and both cost more than this resolution's restriction:
- One combined vtable per
(T, {aspect set}). Keeps the pointer at 2 words, but: vtable generation is no longer one-per-(T, Aspect), it's one per-(T, specific *set* of aspects)— a codebase usingdyn A+B,dyn A+C,dyn A+B+Cfor the sameTneeds three separate combined vtables, not one reused three ways, and nothing bounds how long a+-list can be.dyn A+Banddyn B+Amust be the same type, requiring a canonicalization step (sort/intern the aspect set) before vtable lookup — new compiler machinery, not an extension of the existing one. Widening breaks: narrowingdyn A+B+Ctodyn A+Bis free under the accepted resolution (same representation, drop a static check); under combined vtables it isn't, unless the compiler also generated the{A,B}vtable separately, or guarantees every subset-vtable is a layout prefix of every superset-vtable that contains it — a real constraint on vtable layout that doesn't exist today and would need its own design and enforcement. And a method-name collision across the combined set (AandBboth declaringdescribe) needs a genuinely new disambiguation rule fordyndispatch — Metel's coherence pass already detects this collision for static dispatch (CollectedImpl.method_names, "the cross-aspect ambiguous-method check," issue #272), but "which vtable slot does a dynamic call resolve to" is unanswered by that check and unaddressed by §3 as written. - The pointer itself grows (
data + vtable_A + vtable_B + ..., N+1 words). Avoids combined-vtable generation — each aspect's already-existing single-aspect vtable is reused as-is — but breaks §2's flat statement that fat pointers are always 2 words:dyn Aspectbecomes variably-sized depending on its own bound list, and every piece of generic code passing adyn-typed value around has to become generic over pointer width too. Same method-name collision problem, unresolved by the representation choice.
Neither has a working reference implementation to check the design against. Rust deliberately doesn't support this —
dyn Display + Debugdoesn't compile — for close to these exact reasons, and multi-principal trait objects have stayed unstable there for years specifically because the vtable-layout questions above don't have a settled answer even in the language most likely to have solved them already. Accepting it unconditionally in Metel would not be reproducing a proven design the way the restriction above does (it's Rust's own answer); it would be original design work with no precedent to verify against. If this is ever revisited, the combined-vtable strategy is the less costly of the two — it preserves the 2-word invariant, which matters more broadly than its generation cost does, and that cost is bounded by what a program actually coerces, the same "only what's used gets generated" property vtables already have; the growing-pointer strategy's variable width would leak into generic-code ABI in a way that's much harder to contain later. -
dyn Aspect + Send. Whether marker aspects (Send,Sync) may appear indynbounds — e.g.,dyn Display + Sendto express a sendable aspect object — is deferred to UQ1.Resolved 2026-08-25, as a direct instance of UQ1's answer.
Send/Syncare marker aspects (§3), sodyn Display + Sendis exactly the general rule above withSendas one of the (any number of) trailing marker aspects — no special case needed beyond UQ1's own resolution. -
Moved to RFC-0141, 2026-08-25. Whether
dyn Aspectmay appear inside a branded allocator type — e.g.,@[Rc<'b>] dyn Aspect— is specific to the region-tagged form and doesn't apply until that form exists; tracked there instead of here. -
Vtable caching. Whether vtables are generated per-crate or per-coercion-site, and how they interact with separate compilation, is an implementation question deferred to the compiler RFC.Resolved and confirmed 2026-08-25 for the current interpreter; the separately-compiled-backend case stays genuinely deferred. Vtable generation is lazy and memoized, keyed by
(concrete type's SymbolId, aspect's SymbolId)— the same identity pair RFC-0060's coherence pass (metel-frontend/src/coherence.rs,4-implemented) already groups impls by (by_aspect: HashMap<SymbolId, Vec<CollectedImpl>>, eachCollectedImplalready carryingmethod_namesand the target type's canonical key). A vtable for(T, Aspect)is materialized the first time that pair is actually coerced todyn Aspectanywhere in the program, from the matching already-coherence-checked impl, and cached for the rest of the run — not regenerated per coercion site. See §2."Per-crate" doesn't currently apply: Metel today has no separately-compiled, separately-linked compilation units — a program (or module graph) is coherence-checked as one pass, and vtable generation piggybacks directly on that pass's output rather than needing its own discovery mechanism. This answers the question for the interpreter as it exists today; it does not answer vtable ABI stability across a future ahead-of-time or separately-compiled backend, which stays deferred to a compiler RFC — a real question, but one with no consumer today, the same standard RFC-0141's own dependency on RFC-0063 is held to. If a compiler RFC is ever written, this resolution (lazy + memoized, piggybacking on coherence output) is what needs re-examining for cross-unit consistency, not something to re-derive from scratch.
References
- RFC-0035 — parameter-position
impl Aspect; static dispatch counterpart. - RFC-0037 (Return-Position impl Aspect) — return-type static dispatch; use
dyninstead when different concrete types may be returned at runtime. - RFC-0060 (Aspect Impl Coherence,
4-implemented) — coherence rules determine which(T, Aspect)pairs have vtables generated. - RFC-0071 (Ownership and Move Semantics,
3-integrated, move-check implemented behind--move-check) — drop and move semantics of the owned form. - RFC-0067a (References,
4-implemented) — the borrowed forms (&dyn Aspect,&var dyn Aspect) need nothing beyond this. - RFC-0045 (Mutable Address-Of for Lvalue Paths,
4-implemented) — the existing fat-pointer pattern (FieldReference) §2 points to as precedent. - RFC-0141 (Aspect Objects: Explicit Allocator Placement) — the
@[r] dyn Aspectregion-tagged extension, split out 2026-08-25; depends on this RFC and RFC-0063. - RFC-0137 (Nominal Types as Branded Rows,
3-integrated) — added 2026-08-27: a narrowed residual's row could otherwise reach §5's coercion-to-dyn Aspectboundary with no static check against itsDropimpl's required field set; §5 above states the resulting checkpoint.
Decision
Outcome: Accepted (2026-07-01); split 2026-08-25 into this RFC (implicit
allocation, no RFC-0063 dependency) and RFC-0141 (explicit allocator placement,
depends on RFC-0063). Nothing about the accepted design changed in the split — every
section already described dyn Aspect against the interpreter's existing implicit
allocation; only the region-tagged extension moved out, since it needed a real,
separate dependency this RFC's own core doesn't share.
Target: metel-core#837.