Skip to main content
v0.13.0
rfc-0008implementedDiscussion ↗

Aspect Objects

Status — accepted. Depends on RFC-0060 (Aspect Impl Coherence, 4-implemented). Specifies dyn Aspect as the mechanism for runtime polymorphism: values whose concrete type is erased at compile time and dispatch happens through a vtable. Complements RFC-0037 (return-position impl 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 was 2-accepted at draft time and is still not integrated or implemented today — checked directly against metel-frontend/src/grammar.pest on develop: neither @[ allocator-handle syntax nor the dyn keyword exists in the grammar, and there's no vtable machinery anywhere in metel-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 by Array/Reference/MutReference with no @[r] annotation anywhere) that the representation below already reuses. The explicit @[r] dyn Aspect region-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 described dyn Aspect against 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-implemented coherence 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::drop now takes self: &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 Aspect with 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 / the Callable<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). The dyn <Aspect> syntax itself is unchanged — in v0.13.0 there is simply no stdlib Callable aspect, so dyn Callable<…> is an unknown-aspect error unless the program declares its own; Callable is not reserved. Every other dyn Aspect in 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 mechanism Array/Reference/ MutReference already 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 Drop destructor if the concrete type implements Drop.

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_string takes &Self, returns String)
  • Callable<A, B>(not implemented; no stdlib Callable aspect exists — deferred to RFC-0161, which owns its object-safety story) would be object-safe
  • Drop — object-safe (drop takes self: &var Self, RFC-0071). The vtable's drop function pointer (§2/§5) is separate per-type metadata every dyn Aspect vtable carries, unrelated to object safety.
  • Clonenot object-safe (clone returns Self)
  • Derefnot object-safe (associated type Target in method signature)
  • Send, Sync — marker aspects with no methods; object-safe but rarely used as dyn

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:

  • T implements Aspect, AND
  • T is coerced to dyn Aspect somewhere 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:

  1. Calls the concrete type's Drop destructor via the vtable's drop pointer, if present.
  2. 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 Aspectdyn Aspect
Concrete type known atCompile timeRuntime
DispatchStatic (monomorphised)Dynamic (vtable)
AllocationNone (stack or inline)Required (behind pointer — implicit today; RFC-0141 adds explicit control)
Multiple types in one containerNoYes
OverheadZeroIndirect 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

  1. Multi-aspect bounds. Whether dyn Aspect1 + Aspect2 is 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 out Send/Sync as 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 + Marker is representationally identical to plain dyn Aspect — same fat pointer, same vtable — so it widens implicitly to dyn Aspect wherever needed (dropping a marker guarantee is always safe).
    • + is order-independent: dyn Display + Send and dyn Send + Display name the same type.
    • Two method-bearing aspects on one dyn type (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 individual Display/Debug impls); 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 using dyn A+B, dyn A+C, dyn A+B+C for the same T needs three separate combined vtables, not one reused three ways, and nothing bounds how long a +-list can be. dyn A+B and dyn B+A must 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: narrowing dyn A+B+C to dyn A+B is 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 (A and B both declaring describe) needs a genuinely new disambiguation rule for dyn dispatch — 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 Aspect becomes variably-sized depending on its own bound list, and every piece of generic code passing a dyn-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 + Debug doesn'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.

  2. dyn Aspect + Send. Whether marker aspects (Send, Sync) may appear in dyn bounds — e.g., dyn Display + Send to express a sendable aspect object — is deferred to UQ1.

    Resolved 2026-08-25, as a direct instance of UQ1's answer. Send/Sync are marker aspects (§3), so dyn Display + Send is exactly the general rule above with Send as one of the (any number of) trailing marker aspects — no special case needed beyond UQ1's own resolution.

  3. Moved to RFC-0141, 2026-08-25. Whether dyn Aspect may 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.

  4. 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>>, each CollectedImpl already carrying method_names and the target type's canonical key). A vtable for (T, Aspect) is materialized the first time that pair is actually coerced to dyn Aspect anywhere 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 dyn instead 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 Aspect region-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 Aspect boundary with no static check against its Drop impl'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.