Skip to content

The Rust Engineering Handbook / Chapter 21

Static Dispatch, Dynamic Dispatch, and dyn Compatibility

Choose where polymorphism is resolved by comparing generic, trait-object, and enum strategies across extensibility, allocation, code size, and indirection.

Put the choice at the boundary that knows enough

A settlement service supports two posting policies today. Operators must choose one from configuration when the process starts, and another team may supply policies later. The policy decision itself is tiny. The architectural question is not whether Rust can express polymorphism; generics, trait objects, and enums all can. The question is which boundary knows the concrete type, and whether that boundary is allowed to keep knowing it.

If the caller knows the type during compilation, a generic parameter is usually the honest contract. If a finite set belongs to one product, an enum makes closure and exhaustiveness explicit. If values of unrelated concrete types must cross one runtime boundary, a trait object erases type identity and pays for indirection there.

The working rule is to resolve polymorphism as early as the system’s variability permits. Erase a concrete type only where runtime heterogeneity or independent extension requires it, and make allocation, lifetime, thread-safety, and compatibility costs explicit at that boundary.

The previous coherence rules still apply. Dynamic dispatch does not create permission to implement foreign traits, and static dispatch does not make blanket implementations harmless. Dispatch chooses how a legal trait relationship becomes a call.

One strategy API, three selection models

The executable lab uses this deliberately small behavior:

pub trait PostingStrategy {
    fn decide(&self, posting: &Posting) -> Decision;
}

pub fn decide_static<S: PostingStrategy>(
    strategy: &S,
    posting: &Posting,
) -> Decision {
    strategy.decide(posting)
}

pub fn decide_dynamic(
    strategy: &dyn PostingStrategy,
    posting: &Posting,
) -> Decision {
    strategy.decide(posting)
}

The generic function says that each call site has some concrete S. Rust can monomorphize the function for concrete strategy types and may inline through the call. The trait-object function says that this function accepts any erased strategy implementing the interface. It receives a wide reference carrying access to both a value and dispatch metadata, then selects the method through a vtable.

Neither signature allocates. &dyn PostingStrategy borrows an already existing value. Allocation appears when ownership or heterogeneous storage requires an owning pointer such as Box<dyn PostingStrategy>, shared ownership such as Arc<dyn PostingStrategy + Send + Sync>, or a collection of boxed objects. “Trait object” and “heap allocation” are related design choices, not synonyms.

The closed alternative puts the supported set in the type:

pub enum BuiltInStrategy {
    Maximum(u64),
    RejectAll,
}

impl BuiltInStrategy {
    pub fn decide(self, posting: &Posting) -> Decision {
        match self {
            Self::Maximum(max) if posting.cents <= max => Decision::Approve,
            Self::Maximum(_) | Self::RejectAll => Decision::Reject,
        }
    }
}

This is runtime selection without an open implementation set. The value carries a discriminant and variant data; a match selects behavior. Adding a variant requires editing the enum and every exhaustive match, which is an advantage when the set is product-owned and a limitation when third parties must extend it.

Three call paths compare static dispatch through concrete generated code, dynamic dispatch through a wide pointer and vtable, and closed enum dispatch through a match on a variant. Axes call attention to code size and indirection rather than declaring one universally faster.
Static, dynamic, and enum dispatch put type selection at different boundaries. The useful comparison includes extensibility, representation, and ownership—not only call latency.

Static dispatch retains concrete identity

A generic parameter such as S: PostingStrategy preserves the concrete type in the caller’s instantiation. This enables specialization by ordinary optimization: inlining, constant propagation, and removal of abstraction layers may occur when the optimizer has enough visibility. Those optimizations are implementation outcomes, not language guarantees. The semantic guarantee is that S is one concrete type satisfying the bound for that instantiation.

Monomorphization can duplicate machine code across instantiations. Duplication is not automatically “code bloat”: different types may produce meaningfully different code, and linker or optimizer passes may merge identical bodies. Measure the release artifact on representative targets. A hot, small generic method may gain from inlining while a large cold generic body may inflate instruction footprint and build time.

Keep generic edges narrow when variation is shallow. A generic adapter can extract concrete data and call a non-generic core. This contains the number and size of monomorphized bodies without giving up the type-checked boundary.

Static dispatch also shapes compilation. Public generic bodies are compiled in downstream contexts, so changes can affect consumer build time and code generation. That is a library-engineering cost even when runtime benchmarks look excellent.

impl Trait is type hiding, not runtime dispatch

impl PostingStrategy in an argument position is convenient generic syntax: the caller still supplies one concrete type. In a return position, it exposes one opaque concrete type selected by the function body. All return paths must resolve to that same hidden type.

That makes impl Trait ideal for returning an iterator or closure whose exact compiler-generated name should not enter the API. It does not let one branch return MaximumStrategy and another return RejectAll merely because both implement the trait. Use an enum for a closed branch set or a trait object for erased heterogeneous results.

Opaque return types preserve static dispatch while hiding representation from ordinary callers. They can therefore protect an API from spelling complex adapter types, but they do not promise a stable concrete layout or make every change compatibility-neutral. Public API and SemVer analysis still belongs at the library boundary.

A trait object is a value plus dispatch metadata

The Reference defines a trait object as a dynamically sized type formed from a dyn-compatible base trait, optional auto traits, and at most one lifetime bound. A pointer to it is wide: it carries a pointer to the concrete value and metadata used to locate the implementation’s methods. Standard documentation describes that metadata as a vtable containing method function pointers.

Do not publish assumptions about vtable field order, symbol names, or binary layout as a stable plugin ABI. Rust’s ordinary trait-object representation is useful within one Rust program, but the language does not turn it into a stable cross-version or cross-compiler ABI. A dynamically loaded plugin boundary needs an explicit ABI strategy, version handshake, ownership rules, panic policy, and allocator policy. A C-compatible function table or a purpose-built stable-ABI layer is often more honest than exporting raw Rust trait objects.

Dynamic dispatch normally prevents ordinary direct inlining at the call site and adds an indirect call. Its material cost depends on call frequency, work per call, predictability, cache behavior, and surrounding optimization. For I/O-heavy policy work it may disappear into noise. In a tight element loop it may matter. Benchmark the real boundary; do not replace evidence with “vtables are slow.”

Dyn compatibility is an API design constraint

Only a dyn-compatible trait can be the base of dyn Trait. The complete rules live in the Reference, but several constraints explain most production diagnostics:

  • The trait cannot require Self: Sized as a supertrait.
  • Dispatchable methods cannot have type parameters.
  • Dispatchable methods must use an allowed receiver form such as &self, &mut self, or Box<Self>.
  • Dispatchable methods cannot expose Self in unsupported argument or return positions.
  • Associated constants and generic associated types prevent dyn compatibility under the documented rules.
  • A method intended only for concrete implementers can opt out of object dispatch with where Self: Sized.

The rejected fixture makes a generic method part of the erased interface:

trait Factory {
    fn create<T>(&self) -> T
    where
        T: Default;
}

fn install(_factory: &dyn Factory) {}

There is no single vtable entry for “create every possible T.” The caller chooses T, but the erased implementation would need an open-ended family of monomorphized methods. Rust rejects the trait-object use with E0038.

Three repairs express three different contracts. Move the type choice into an associated type when each implementation has one output; return an erased product such as Box<dyn Product> when runtime heterogeneity is intended; or keep the generic method concrete-only with where Self: Sized and put a smaller dyn-compatible method on the object interface. The shortest compiling repair is not necessarily the right public model.

Object interfaces should be designed for erasure

Treat dyn compatibility as more than a compiler checklist. Erasure removes operations that depend on knowing concrete size or identity. A good object interface asks for behavior that can be described without recovering the hidden type.

Avoid adding downcasting as the default escape hatch. Any-based downcasting is appropriate at some framework boundaries, but widespread downcast_ref::<Concrete>() recreates a hidden enum without exhaustiveness and couples consumers to implementations. If callers must branch on every supported kind, the set may actually be closed and deserve an enum.

Methods returning borrowed data must connect lifetimes to the receiver accurately. fn label(&self) -> &str can dispatch through an object because the output borrow is tied to the call. Owning an object for longer requires an explicit object lifetime, often inferred as 'static in boxed registries. 'static means the erased value contains no shorter-lived borrowed data; it does not mean the object lives forever.

Auto-trait bounds are part of the erased type. Box<dyn PostingStrategy> is not interchangeable with Box<dyn PostingStrategy + Send + Sync>. If the registry crosses threads, require the capabilities at insertion so failures occur at the boundary rather than deep in task creation.

Runtime selection does not dictate ownership

Choose the narrowest pointer that matches the lifecycle. A temporary call into a caller-owned value needs only &dyn PostingStrategy; neither ownership transfer nor allocation follows from that borrow. A stateful call can use &mut dyn PostingStrategy, making exclusive access visible without taking the value away from its owner.

When one component must own the erased strategy, Box<dyn PostingStrategy> provides single ownership at the cost of a heap allocation. If several threads must share it, Arc<dyn PostingStrategy + Send + Sync> adds atomic reference counting and requires the hidden value to satisfy the thread-safety contract. An interface that requires a pinned receiver needs a valid pinned pointer such as Pin<Box<dyn Trait>>; the restriction on movement is then part of the API, not an incidental storage choice.

Do not start with Arc merely because the type is dynamic. Shared ownership can obscure shutdown order and create reference cycles. Do not box a short-lived strategy merely to make a signature look uniform. Conversely, do not retain borrowed plugin state past its owner; the compiler’s object lifetime diagnostics are exposing a real lifecycle mismatch.

Compare the three designs at the product boundary

For the runtime-selectable settlement strategy, ask who owns extension:

Generic parameter. Best when an application assembles a known pipeline at compile time, tests concrete combinations, or keeps the strategy behind another generic owner. It preserves optimization opportunity and avoids object allocation. It can propagate type parameters through architecture and cannot put different strategy types in one homogeneous collection without another wrapper.

Trait object. Best when configuration, dependency injection, or a host registry must hold values of unrelated types behind one behavior contract. It limits generic propagation and can reduce duplicated call-site code. It introduces erasure, indirect calls, and explicit pointer/lifetime choices. It is open to legal implementations, but not automatically a stable binary plugin system.

Enum. Best when the product owns a finite strategy set and wants exhaustive handling, straightforward serialization, and behavior co-located with variants. It avoids vtable dispatch and can store values inline, though the enum is sized to accommodate its largest variant plus discriminant and padding. Adding third-party cases requires changing the defining crate.

A mature system can combine them: deserialize a closed configuration enum, construct one of several concrete strategies, erase it once behind Box<dyn PostingStrategy + Send + Sync>, then keep the inner numerical pipeline generic or concrete. The key is to localize erasure rather than let it leak through every layer.

Failure modes that compile

  • Making every internal function generic, producing type-parameter plumbing and costly instantiation far from the true variation point.
  • Boxing every strategy even when a borrowed trait object would express the lifecycle and avoid allocation.
  • Replacing a product-owned closed set with downcast-heavy trait objects.
  • Treating impl Trait as if it selected among multiple runtime types.
  • Adding Send + Sync + 'static reflexively, excluding useful borrowed or single-threaded implementations without a real boundary requirement.
  • Exposing raw Rust trait objects as a stable dynamic-library ABI.
  • Declaring dynamic dispatch slow from a micro-model without measuring the workload.
  • Optimizing away an enum or object boundary that makes configuration, testing, and operations materially clearer.

Operationally, strategy selection should be observable without leaking concrete implementation details into every metric label. Record a bounded strategy identifier, configuration version, and rejection reason. Avoid unbounded type names as metric dimensions. Decide whether a strategy panic may unwind through the host; process isolation or a C ABI requires a much stricter failure contract than an in-process call.

Practice: design one strategy API three ways

Implement a maximum-posting policy and a reject-all policy as:

  1. a generic decide_static<S: PostingStrategy> function;
  2. a borrowed &dyn PostingStrategy function plus an owning runtime registry;
  3. a BuiltInStrategy enum with exhaustive matching.

For each design, record who selects the strategy, whether the set is open, where allocation occurs, whether borrowed implementations are permitted, what thread bounds are required, and how a new strategy changes downstream code. Add a deliberately non-dyn-compatible generic method, preserve its E0038 fixture, and choose a repair based on who should select the output type. Finally, benchmark only if the decision frequency and work per call make dispatch plausibly significant.

Dispatch review card

  • Does the caller know the concrete type at compile time?
  • Is the implementation set product-owned and finite?
  • Must unrelated concrete values share storage or cross one runtime boundary?
  • Does the trait describe behavior possible after type erasure?
  • Is allocation required by ownership, or merely assumed because dyn appears?
  • Are object lifetimes and Send/Sync bounds imposed at the real boundary?
  • Is a plugin boundary in-process Rust composition or a versioned binary ABI?
  • Have code size, build time, indirect-call cost, and operational clarity been measured or explicitly scoped?

Durable takeaways

Static dispatch preserves concrete identity and optimization opportunity. Dynamic dispatch erases identity at an explicit runtime boundary. Enums model a closed runtime choice with exhaustive knowledge. None is the universal “fast” or “flexible” answer: the correct design follows who owns the implementation set, when selection occurs, and how values are stored. The next abstraction mechanism—closures—uses the same principle at a smaller scale: a callable’s useful interface follows what its hidden environment permits.

Sources and version notes