RFA-368 · Case file with fixtures · Case 340 of 694 · Compiler evidence
A dyn Trait Object Allows Only One Non-Auto Base Trait
A trait object has one dyn-compatible base trait plus auto traits and a lifetime. Define a local ReadWrite supertrait with a blanket implementation when one vtable must expose both capabilities.
- 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
- A trait object has one dyn-compatible base trait plus permitted auto traits and a lifetime, rather than synthesizing several ordinary method sets.
- First discriminating check
- Choose static generic dispatch or define one local supertrait with a blanket implementation when the runtime object must expose both capabilities.
I wanted one borrowed stream that supported both reading and writing, so I wrote &mut (dyn Read + Write). Rust reported E0225: only auto traits can be used as additional traits in a trait object.
The failing program reduces the problem to one parameter. Both capabilities are individually dyn-compatible, but they cannot both appear as separate non-auto base traits.
A trait object has one base trait
The Rust Reference defines a trait object as one dyn-compatible base trait, any number of auto traits, and at most one lifetime bound. The object carries a data pointer and a virtual method table for that base trait and its supertraits.
Read and Write are both ordinary non-auto traits. The syntax is not interpreted as “construct a new combined interface.” Rust needs one base-trait identity for the trait object's vtable contract.
Parentheses improve parsing but cannot change this type rule.
Send and Sync are different additions
Types such as dyn Read + Send + Sync are valid because Send and Sync are auto traits. They describe properties automatically derived from the concrete type rather than adding ordinary method sets as another base interface.
This is why compiler help says “only auto traits” rather than “only one trait of any kind.” A lifetime such as + 'static is also allowed under its separate rule.
I identify which bound category I am adding instead of copying a valid-looking plus list from generics to a trait object.
A local supertrait creates one interface
The repaired program declares:
trait ReadWrite: Read + Write {}
impl<T: Read + Write + ?Sized> ReadWrite for T {}
Now dyn ReadWrite has one base trait. Through supertraits, its vtable contract includes access to both Read and Write methods.
The blanket implementation makes every suitable type implement the local interface automatically. A Cursor<Vec<u8>> can therefore be passed to the repaired function.
Generic bounds do allow several ordinary traits
A generic function can write T: Read + Write because monomorphization selects one concrete T and proves it implements both bounds. This is static dispatch unless the chosen type itself introduces indirection.
fn use_duplex<T: Read + Write>(stream: &mut T) { /* ... */ }
The generic form may be preferable when callers and code size fit. The trait-object form is useful for heterogeneous runtime values, plugin-style boundaries, or reducing monomorphized copies.
The two forms express different dispatch and ownership designs, not merely alternative syntax.
The supertrait must itself be dyn-compatible
Combining method sets into a local trait does not bypass dyn-compatibility. If a required method has generic type parameters, returns opaque impl Trait, uses Self in a disallowed position, or has another incompatible feature, dyn ReadWrite can still fail.
I check the complete supertrait graph. Every supertrait must be dyn-compatible for the combined base trait to serve as a trait object.
Methods intended only for sized implementors can sometimes add where Self: Sized, excluding those methods from dynamic dispatch while keeping the remaining trait usable as dyn.
Avoid accidentally changing the public abstraction
A local trait is a new API concept. Naming it ReadWrite may be enough internally, while a public library should define what duplex behavior means, how flushing works, whether seeking is required, and which error or concurrency guarantees callers can expect.
Method availability alone does not create a coherent protocol. For example, simultaneous bidirectional network flow may need independent read and write halves rather than one &mut object.
I use the smallest trait that represents the actual boundary, not an ever-growing capability bundle.
Blanket implementations affect future coherence
The blanket impl<T: Read + Write + ?Sized> ReadWrite for T is convenient and normally correct for a marker-style combination. It also means I cannot later provide a more specific conflicting ReadWrite implementation for one type on stable Rust.
If the trait needs customizable behavior, I define methods and implementation policy carefully rather than adding a blanket implementation automatically.
The trait is local, so the orphan rules permit this blanket implementation over foreign Read and Write traits.
Tests should exercise both methods
A repaired type that happens to implement only one capability should not satisfy the combined interface. I compile the blanket bound and call a write method through dyn ReadWrite; a fuller test can seek and read the written bytes too.
The compile-fail case asserts E0225 and the diagnostic phrase about additional non-auto traits. Pinning Rust 1.98.1 keeps the evidence wording reviewable while the Reference provides the lasting language rule.
The core principle is that plus means different things by context
In a generic bound, Read + Write lists obligations on a concrete type. In a trait object, the list describes one base trait plus permitted auto and lifetime bounds. It does not synthesize a structural interface from several ordinary traits.
Defining one supertrait gives that combined interface a name and one vtable identity. The compiler error is then a useful design prompt: decide whether this boundary needs static dispatch or a real runtime abstraction.