Skip to content

The Rust Engineering Handbook

Appendix D — Trait and Dispatch Reference

Choose trait bounds, associated types, and static or dynamic dispatch by abstraction contract, compatibility surface, and measured cost.

A service accepts a dozen request handlers. One design makes the server generic over a handler type; another stores Arc<dyn Handler + Send + Sync>. Both can implement the same behavior. They do not create the same system.

The generic design keeps the concrete type known, permits monomorphization and inlining, and makes heterogeneity require an enum or another layer. The trait-object design erases concrete types behind a pointer, permits a runtime-selected collection, restricts the callable surface to dyn-compatible items, and performs virtual calls. Neither choice decides allocation by itself. Neither choice is universally faster. The selection starts with who must choose the implementation and when.

Use this reference in four passes: define the behavior contract, verify that implementations are legal under coherence, decide how associated types and generic parameters express type relationships, then select static or dynamic dispatch. Auto traits, closure traits, and operator traits add separate contracts; do not collapse them into one “implements trait” question.

Common bounds as caller guarantees

A bound grants the generic implementation permission to use a capability:

fn render<T>(value: &T) -> String
where
    T: std::fmt::Display + ?Sized,
{
    value.to_string()
}

Read T: Display as “for every caller-selected T, this function may rely on a Display implementation.” ?Sized relaxes the implicit Sized bound so the function can accept dynamically sized values through references. It does not make T passed by value possible without indirection.

Frequently useful bounds have distinct meanings:

Bound Capability promised Review question
T: Clone explicit type-defined duplication is duplication semantically required, and can its cost be large?
T: Copy implicit bitwise duplication with Clone consistency obligations is independent implicit duplication appropriate for this value?
T: Default a conventional default value is “default” meaningful or hiding required configuration?
T: Eq + Hash equivalence and hashing suitable for hash-based lookup are implementations mutually consistent?
T: Ord total ordering does the domain truly have a total order?
T: Display user-facing formatting is this actually diagnostic formatting (Debug) or a stable external format?
T: Send ownership may cross thread boundaries safely does the surrounding lifetime and shutdown design permit transfer?
T: Sync shared references may cross thread boundaries safely is shared access useful, and what controls logical mutation?
T: 'static no borrowed data shorter than 'static does the storage/task boundary need independence from the current scope?
T: ?Sized T need not have compile-time-known size is the value always behind a pointer/reference capable of carrying metadata?

Put bounds where they express the narrowest usable contract. An implementation block with impl<T: Clone> Store<T> makes every method unavailable unless T: Clone; a method-level bound can keep unrelated operations available. Conversely, a type invariant that truly requires a bound belongs at the type or implementation boundary.

Blanket bounds are API commitments. Adding an unnecessary bound rejects types and may force allocations, cloning, thread-safety, or formatting policies on callers. Removing a bound is usually more permissive; changing overlapping implementations or selection behavior can have subtler compatibility consequences.

Associated types versus generic trait parameters

An associated type says one implementation chooses one related type:

trait Encode {
    type Error;
    fn encode(&self, output: &mut String) -> Result<(), Self::Error>;
}

For a particular Encoder, Encoder::Error is fixed by its implementation. Callers constrain it with equality syntax:

fn encode_infallible<T>(value: &T, output: &mut String)
where
    T: Encode<Error = std::convert::Infallible>,
{
    match value.encode(output) {
        Ok(()) => {}
        Err(never) => match never {},
    }
}

A generic trait parameter permits multiple implementations for different parameter choices when coherence allows:

trait Convert<Input> {
    type Output;
    fn convert(&self, input: Input) -> Self::Output;
}

Choose associated types when the relation is intrinsic and should be inferred from Self: iterator item, dereference target, future output, encoder error family. Choose a trait parameter when one implementing type should support multiple distinct input contracts or when the caller selects the relation.

Associated constants attach values rather than types. Generic associated types attach a family of types indexed by lifetimes or types, useful when a returned type borrows from each method call. They increase expressive power and can increase bound complexity; preserve consumer-shaped compile tests.

Coherence: who may implement what

Trait lookup must produce a coherent implementation. Rust’s orphan rules broadly require a local trait or a local type in an implementation, with additional ordering/coverage rules for generic parameters. This prevents two downstream crates from independently supplying conflicting implementations of a foreign trait for a foreign type.

This is rejected:

impl std::fmt::Display for Vec<u8> {
    // foreign trait for foreign type
}

The standard repair is a local newtype:

struct Bytes(Vec<u8>);

impl std::fmt::Display for Bytes {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{} bytes", self.0.len())
    }
}

The newtype is not boilerplate alone. It creates a local semantic type that can enforce invariants, define formatting, control conversions, and own compatibility decisions.

Blanket implementations trade convenience for implementation space:

trait Inspect { fn inspect(&self) -> String; }

impl<T: std::fmt::Debug> Inspect for T {
    fn inspect(&self) -> String { format!("{self:?}") }
}

That implementation reserves Inspect for every T: Debug; a later specialized Inspect for one such type would overlap on stable Rust. Library authors should treat blanket implementations and public trait implementations as long-lived compatibility choices. Sealed-trait patterns can deliberately restrict downstream implementations when exhaustive control is necessary, but this reduces extensibility and should be explicit.

Negative reasoning and specialization have unstable or limited surfaces. Do not design a stable public API around the hope that the compiler will later choose the “most specific” overlapping implementation.

Dyn compatibility and the erased surface

A trait object such as &dyn Handler consists conceptually of a data pointer plus metadata identifying implementations of dyn-compatible methods. The old term “object safety” remains common; current official documentation uses dyn compatibility.

A useful dyn-compatible service trait keeps runtime calls independent of the erased concrete type:

trait Handler {
    fn handle(&self, input: &str) -> usize;
}

fn run(handler: &dyn Handler, input: &str) -> usize {
    handler.handle(input)
}

Common reasons a trait is not dyn-compatible include methods with type parameters, methods that return bare Self, associated constants, and requirements involving Self: Sized at the trait level. Methods explicitly limited with where Self: Sized can remain available to concrete users while being excluded from the trait-object dispatch surface.

Associated types do not automatically forbid trait objects, but the erased type often must specify them:

fn consume(source: &mut dyn Iterator<Item = u8>) {
    for byte in source { std::hint::black_box(byte); }
}

Returning impl Trait and returning Box<dyn Trait> are different. impl Trait in return position hides one concrete type selected by the function implementation. All return paths must resolve to that one hidden type. A trait object can erase different concrete implementations behind a common runtime surface, usually through a reference or owning pointer.

When a public trait must serve both generic and erased consumers, separate the object surface from generic convenience methods. Extension traits and free generic functions can keep the core dyn-compatible without impoverishing concrete callers.

Static versus dynamic dispatch

Static dispatch uses a concrete or generic type known after monomorphization:

fn execute<H: Handler>(handler: &H, input: &str) -> usize {
    handler.handle(input)
}

Dynamic dispatch uses a trait object:

fn execute_erased(handler: &dyn Handler, input: &str) -> usize {
    handler.handle(input)
}

Choose among them by system constraints:

Constraint Generic/static fit Trait-object/dynamic fit
implementation chosen by caller at compile time strong possible but unnecessary erasure
heterogeneous runtime collection enum or custom erasure needed natural behind pointers
closed implementation set enum can make cases exhaustive works but hides exhaustiveness
plugin/config selection awkward without indirection natural if ABI/loading issues are separately solved
inlining across behavior boundary possible, not guaranteed commonly inhibited at virtual call site
compile time and code size monomorphization may multiply code one erased call path may reduce duplication
binary compatibility generics expose code/type coupling trait objects alone do not create a stable Rust ABI
ownership value/reference as signature states borrowed or owning pointer chosen separately
A dispatch decision map routes a known implementation set toward generic, impl Trait, or enum-based static dispatch and an open runtime-selected set toward dyn Trait, while coherence, auto traits, and associated types remain separate review questions.
Dispatch follows who selects the implementation and when. Coherence, type relationships, ownership, auto traits, code size, and call cost remain independent obligations after that choice.

The map’s dotted boxes are potential engineering effects, not blanket guarantees. A monomorphized call may still remain out of line. A vtable call need not matter in a workload dominated by I/O, allocation, hashing, or locking.

Dynamic dispatch does not require heap allocation: &dyn Trait is borrowed. Box<dyn Trait> allocates because Box owns a dynamically sized value, not because virtual dispatch inherently allocates. Static dispatch may still allocate inside the implementation.

Performance depends on call frequency, predictability, optimization boundaries, code layout, instruction-cache pressure, and the work behind the call. Measure a production-shaped workload. A virtual call in an I/O operation is usually a different concern from one in a tiny inner loop. Generic duplication can improve local optimization while harming build time or instruction-cache behavior.

An enum is a third option when the implementation set is closed. It supports heterogeneous storage without vtables, makes cases explicit, and allows per-variant data. It also couples the dispatcher to every variant and requires source changes for extension.

Auto traits propagate structural properties

Send and Sync are unsafe auto traits implemented automatically when a type’s components permit it, except where explicit positive or negative implementations and language/library rules apply. Roughly, Send permits ownership transfer between threads; Sync means shared references can be transferred safely. Neither proves race-free business logic, fair locking, bounded latency, or correct shutdown.

Auto-trait behavior propagates through wrapper and field choices. Adding a non-Send field can make an otherwise movable public type non-Send. An Arc<T> is Send and Sync only when its contained type satisfies the necessary bounds; atomic reference counting does not sanitize unsafe interior behavior.

Other auto traits include Unpin, UnwindSafe, and RefUnwindSafe, each with a specific semantic role. Do not manually implement unsafe auto traits without an audited invariant. For public abstractions, assert expected positive behavior in compile-time tests and preserve rejected evidence where non-transfer is intentional. Auto-trait changes can be semver-significant because downstream thread/task composition may start or stop compiling.

Closure traits describe capture use

Closures implement one or more call traits according to how their body uses captured state:

  • FnOnce consumes the closure value on a call and is implemented by every closure; a capture moved out can make only one call possible.
  • FnMut permits repeated calls while mutating captured state and requires mutable access to the closure.
  • Fn permits calls through shared access when captures are not mutated or moved out in a way that requires exclusivity.

Accept the weakest requirement that satisfies the operation. A one-shot transaction callback can accept FnOnce; requiring Fn would reject useful consuming closures. A retry policy needs repeated invocation and must decide whether mutation is allowed, so FnMut may be accurate. A callback shared concurrently may additionally require Send + Sync, but those bounds describe transfer/sharing, not the call trait itself.

move changes capture mode, not automatically the call trait. A move closure that only reads an owned string may implement Fn; one that moves that string out when called is only FnOnce.

Function items and function pointers can implement the closure traits, but fn(A) -> R is a concrete pointer type with no captures. dyn Fn(A) -> R is an erased closure interface. impl Fn(A) -> R hides one concrete closure type. Choose with the same closed/open and ownership questions used for other traits.

Operator traits are ordinary API contracts

Operators desugar to traits such as Add, Index, Deref, PartialEq, and IntoIterator, subject to language rules. Implementing one is public semantic design, not merely syntax customization:

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Bytes(u64);

impl std::ops::Add for Bytes {
    type Output = Bytes;

    fn add(self, rhs: Bytes) -> Self::Output {
        Bytes(self.0 + rhs.0)
    }
}

Review the operand ownership and output type. Add<Rhs = Self> may consume values; implementations for references can avoid copying large operands. The output need not be Self, but surprising algebra makes code difficult to reason about.

PartialEq and Hash must agree for hash collections; Eq asserts full equivalence requirements; Ord must agree with equality. Floating-point values therefore require explicit domain policy before becoming keys or totally ordered values. Deref participates in coercion and method lookup, so it should model pointer-like access rather than arbitrary conversion. Index promises reference-like access and uses panic behavior for invalid indexes; a fallible lookup often belongs in a named method.

Operator traits should preserve recognizable laws, document overflow/panic behavior, and avoid hiding expensive I/O, locking, or allocation behind innocent-looking syntax.

Failure modes that compile

  • Adding Clone to every bound can transfer an unclear ownership problem into repeated work.
  • Making a trait generic over every variation can produce difficult types, slow builds, and broad monomorphization without useful optimization.
  • Erasing everything behind Box<dyn Trait> can hide a closed domain model and force allocation/indirection where an enum was clearer.
  • Returning impl Trait cannot provide arbitrary runtime heterogeneity; wrapping branches until they share one concrete type may create needless complexity.
  • A broad blanket implementation can consume future coherence space for downstream or specialized behavior.
  • Adding Send + Sync + 'static “for flexibility” can reject scoped, single-threaded, or borrowing implementations and misstate the architecture.
  • A dyn-compatible core trait overloaded with getters may expose representation instead of behavior.
  • Implementing Deref as a convenience conversion can make method lookup surprising and weaken invariants.
  • Assuming a trait object defines a stable plugin ABI ignores Rust ABI, compiler-version, layout, allocator, panic, and ownership boundaries.

The compiler checks trait selection and language safety conditions. It does not prove that an abstraction boundary is useful, an operator is unsurprising, a virtual call matters, or a blanket implementation leaves enough evolution space.

Applied design exercises

Handler boundary. Design the request-handler service from the opening in three forms: Server<H: Handler>, Server { handlers: Vec<Arc<dyn Handler + Send + Sync>> }, and an enum over three built-in handlers. For each, record who selects implementations, whether the set is open, ownership and shutdown behavior, dyn compatibility, auto-trait requirements, allocation, call frequency, code-size risk, and compatibility implications. Benchmark only after stating a workload hypothesis.

Trait evolution audit. Start with:

trait Store {
    type Error;
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Self::Error>;
}

Evaluate adding a generic decode method, a constructor returning Self, an async operation, an associated constant, and a default helper restricted by Self: Sized. Determine which items belong on the dyn-compatible core, an extension trait, or a concrete adapter. Preserve a compile test for Box<dyn Store<Error = E>> if erasure is promised.

Coherence forecast. For a public formatting/conversion trait, list all blanket implementations being considered and the implementation space each reserves. Try a downstream newtype, a foreign collection, and a type that already satisfies a blanket bound. Explain which desired future implementations overlap and redesign before release.

The companion crate at examples/rust-engineering-handbook/appendices/lifetime-trait-lab/ exercises associated types, generic and erased handler calls, closure adapters, operator traits, auto-derived behavior, and intentional dyn/coherence failures.

Review card

  • State the behavior contract before choosing a trait shape.
  • Put each bound at the narrowest boundary that needs its capability.
  • Use an associated type for one implementation-selected relation; use a parameter when multiple caller-selected relations are intended.
  • Audit orphan and overlap rules before promising downstream extensibility.
  • Keep the erased method surface dyn-compatible; move generic convenience elsewhere.
  • Choose generics, trait objects, or enums from implementation-set and selection-time requirements.
  • Treat allocation, ownership, dispatch, and thread safety as separate axes.
  • Read Send, Sync, and 'static as specific boundary contracts, never a generic “production safe” badge.
  • Select FnOnce, FnMut, or Fn from invocation and capture behavior.
  • Require operator implementations to preserve recognizable semantics and document cost/failure.
  • Measure dispatch cost in context and preserve compile evidence for public trait promises.

Appendix E applies these same questions to conversion and view traits, where superficially similar bounds can imply very different ownership, lookup, and allocation behavior.

Sources and version notes

  • The Rust Reference on traits, implementations, and trait bounds defines the language contracts, coherence restrictions, and bound forms.
  • The Rust Reference dyn compatibility rules are authoritative for trait-object eligibility.
  • Standard-library documentation for Send, Sync, FnOnce, FnMut, and Fn defines their library contracts.
  • The std::ops module documents overloadable operators and their traits.
  • Inlining, devirtualization, code size, and generated call sequences are compiler and workload observations, not blanket language guarantees. Validate performance claims with the publication toolchain and representative artifacts.
  • Examples target Rust 2024. The fixture declares Rust 1.85 as its MSRV; dyn-compatibility wording, diagnostics, and compiler optimization behavior should be rechecked independently before acceptance.