RFA-626 · Case file with fixtures · Case 598 of 694 · Compiler evidence
A dyn Trait Return Value Needs Pointer Indirection
dyn Trait is a dynamically sized erased value. Return one concrete impl Trait, a finite enum, or place heterogeneous implementations behind Box, Arc, or a borrow.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all Rust targets
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Dynamic type erasure was mistaken for a complete value representation even though an erased pointee still needs a sized pointer carrier.
- First discriminating check
- Choose one concrete impl Trait, a finite enum, an owned Box or Arc, or a borrow according to variation and ownership.
A trait can be implemented by types of many different sizes. After the concrete type is erased into dyn Trait, the value is dynamically sized. A normal by-value return slot needs a size and alignment agreed at compile time. The failing fixture tries to return dyn Transport directly and receives E0746.
dyn Trait is the pointee, not the complete handle
A trait object is normally used behind a pointer such as &dyn Trait, Box<dyn Trait>, Rc<dyn Trait>, or Arc<dyn Trait>. The pointer representation carries enough metadata to locate the correct method implementations along with the data address.
The official E0746 explanation presents three main directions: one hidden concrete impl Trait, boxed dynamic dispatch, or an enum covering known alternatives. The Reference defines trait object types.
I choose among them from the variation and ownership required by the interface, not simply from the shortest fix.
impl Trait means one concrete return type
If every branch returns the same concrete implementation, -> impl Transport hides that type from callers while preserving static dispatch. The compiler still knows its exact layout. This is useful for iterator pipelines and implementation privacy.
It cannot return Tcp in one branch and an unrelated Udp in another merely because both implement the trait. All return paths must resolve to one hidden concrete type. Wrapping alternatives in one enum can preserve static dispatch when the set is closed.
Changing the hidden concrete type may still affect auto traits, captured lifetimes, and observable performance. Public API review should not treat opaque syntax as absence of a contract.
Box dyn Trait supports owned heterogeneity
The repaired fixture returns Box<dyn Transport>. The box itself has a known size, owns the implementation, and allows different concrete implementors behind one interface.
This introduces allocation in the shown design and dynamic dispatch for trait methods. In many architecture boundaries that cost is reasonable and clarity matters more. In a per-packet hot loop, an enum or generic parameter may perform and inline better. I measure before claiming either choice is faster overall.
Arc<dyn Trait + Send + Sync> expresses shared cross-thread ownership, but adding those bounds is a real constraint on implementations. A borrowed &dyn Trait avoids ownership transfer and allocation when the provider already outlives the use. The function signature should reveal the needed lifetime.
Object safety limits the erased interface
Not every trait can become a trait object. Methods involving unconstrained Self, generic methods, and associated items have dyn-compatibility rules. Solving E0746 with a pointer may reveal another diagnostic if the trait itself cannot be erased.
The Book’s trait objects chapter shows dynamic dispatch through a shared behaviour interface. I keep that interface small and focused. Large traits make mocking easy at first but couple unrelated capabilities and reduce the set of usable implementors.
Associated types must be specified where required, for example Box<dyn Iterator<Item = Record>>. Lifetimes and auto traits are also part of the object type. Omitting Send can block task spawning later; adding 'static can force unnecessary ownership.
An enum preserves closed-world information
When there are a few known implementations, an enum can own each one and implement the common trait by delegation. It avoids heap allocation in many cases and permits callers to match when variant-specific behaviour matters.
Its size is the largest variant plus representation overhead, so a very large outlier affects every value. Adding a variant requires updating exhaustive matches and can be a public compatibility decision. This is a good trade when the implementation set is intentionally closed.
For plugin systems, downstream extensibility normally favours trait objects. For an internal protocol state machine, a finite enum normally states the truth better.
My E0746 checklist
- Is the return type a bare
dyn Traitwith no pointer indirection? - Do all paths produce one concrete type suitable for
impl Trait? - Is the implementation set closed enough for an enum?
- Who owns the returned value, and for how long?
- Are allocation, dynamic dispatch, and code-size tradeoffs measured where important?
- Which lifetime, associated-type, Send, and Sync bounds belong on the interface?
- Is the trait dyn-compatible and narrow enough for this boundary?
- Does the caller need erased behaviour or concrete variant information?
The core principle is that dynamic erasure does not give a value a static size. E0746 asks for a concrete carrier. I select that carrier from ownership and extensibility: opaque concrete type, finite enum, owned pointer, shared pointer, or borrow.