Crabrix blog

Rust traits and generics: choose a clear dispatch boundary

Design small Rust traits, use generic bounds, and understand borrowed dyn trait objects. Compare dispatch choices without invented performance claims.

Begin with shared behavior you actually need

Two types can have different storage and still support the same useful operation. A temperature sensor might compute a reading from one field, while a counter exposes a stored total. A report may only need a number from either source. A trait names that shared behavior without requiring the two types to share a representation or inherit from the same data structure.

The important design question is what the caller needs to do. Starting with an abstract interface before identifying a real shared operation can create unnecessary generic parameters and awkward methods. This guide builds one small trait, then uses it through both a generic function and a borrowed trait object. All examples use the standard library and can run in Crabrix. We will keep dispatch, ownership and allocation as separate decisions throughout the explanation.

Two differently shaped measuring instruments share one connector and feed direct and selectable routes through an architectural interface.
Share an operation where it helps callers; keep concrete storage and ownership decisions explicit.

A trait is a contract for operations

Our trait, Measure, exposes a read-only method returning an i32. It deliberately says little about how the value is stored or obtained. A Counter returns its stored value; an Offset computes a value from a base and a small adjustment. These are illustrative measurements with a bounded example input, not an overflow-safe numeric framework. If the domain permits extreme values, the operation’s numeric policy must be designed too.

A generic function constrained by T: Measure can call the promised method without knowing which fields a concrete type contains. The bound is useful because it states the capability required by that function. The compiler checks each implementation and use against the trait’s declared interface. It does not prove that the measured number is physically accurate or that two implementations use the same units. Those semantic requirements belong in the trait’s documentation and tests.

trait Measure { fn value(&self) -> i32; }
struct Counter { count: i32 }
struct Offset { base: i32, adjustment: i32 }
impl Measure for Counter { fn value(&self) -> i32 { self.count } }
impl Measure for Offset {
    fn value(&self) -> i32 { self.base + self.adjustment }
}
fn doubled<T: Measure>(source: &T) -> i32 { source.value() * 2 }
fn main() {
    let counter = Counter { count: 7 };
    let offset = Offset { base: 10, adjustment: -2 };
    assert_eq!(doubled(&counter), 14);
    assert_eq!(doubled(&offset), 16);
    println!("Generic checks passed");
}

Generics keep the concrete type available

A generic parameter lets the caller supply a concrete type satisfying the bound. The function body can use the trait’s promised operations, while compilation retains knowledge of the concrete instantiation. This is commonly associated with static dispatch and monomorphization. It is a useful default when one concrete type flows through a call and the API benefits from preserving that type’s relationships.

Do not turn that mechanism into an automatic speed claim. Inlining, optimization, code size and the work inside the operation all affect actual results. Generic code can produce multiple instantiations, while dynamic calls can affect optimization opportunities. The infographic compares where concrete type information is retained or hidden; its arrows are not a benchmark. Choose an understandable API first, then measure a demonstrated performance problem under the real workload.

Generic concrete types flow through separate generic uses, while two implementations can be borrowed through one dyn Measure interface.
Generics preserve concrete type information at the call boundary; a trait object supplies a common interface with dynamic dispatch.

Sources: The Rust Book: traits and shared behavior

Borrowed trait objects can support runtime choice

A &dyn Measure refers to a value through the trait’s interface. The concrete value still exists and still has an owner, but the caller using that reference works through dynamic dispatch. This is useful when a collection or runtime choice needs to treat several concrete implementations uniformly. The trait must meet Rust’s dyn-compatibility requirements before it can be used this way.

A borrowed trait object does not require a Box. The next complete program keeps two concrete values on the stack and stores references to them in an array. The owners remain alive during the iteration. This is a useful correction to the oversimplified rule that dynamic dispatch necessarily means a heap allocation. Allocation follows the chosen owner and container; dispatch follows how methods are selected through the interface.

trait Measure { fn value(&self) -> i32; }
struct Counter(i32);
struct Fixed(i32);
impl Measure for Counter { fn value(&self) -> i32 { self.0 } }
impl Measure for Fixed { fn value(&self) -> i32 { self.0 } }
fn main() {
    let counter = Counter(7);
    let fixed = Fixed(9);
    let sources: [&dyn Measure; 2] = [&counter, &fixed];
    let total: i32 = sources.iter().map(|source| source.value()).sum();
    assert_eq!(total, 16);
    println!("Borrowed total: {total}");
}

Sources: The Rust Reference: dyn compatibility

Keep ownership separate from dispatch

A shared reference permits inspection without taking ownership. A mutable reference permits exclusive access under borrowing rules. An owned Box<dyn Trait> can store a dynamically selected implementation independently of a temporary caller, subject to its lifetime requirements. These shapes solve different storage and access needs. They are not interchangeable decorations added to make a compiler error disappear.

Ask who owns the concrete value, how long the interface must survive, and whether mutation is required. A report that reads a short-lived source can borrow it. A long-lived plugin registry may need owned objects. A mutable service may require &mut self methods and careful exclusive access. Choosing an owner first often makes the dispatch form straightforward, while choosing Box by habit can introduce allocation and lifetime constraints the product never needed.

Small traits make obligations easier to explain

A trait with one coherent responsibility is easier to implement and test than an interface containing every operation a large application might eventually need. If a formatter only reads a value, it should not require implementers to support reset, persistence, networking and deletion. Large interfaces can force meaningless stub methods and make mock implementations appear valid while silently ignoring important behavior.

Keep related operations together when they share a genuine contract. If reading a value also requires a unit, document that relationship or return a type that carries it. If failure is possible, represent it in the return type rather than promising an infallible integer and panicking internally for ordinary environmental errors. Trait design is API design: method names, error behavior, borrowing and units all matter as much as the presence of the trait keyword.

Associated types express relationships, not arbitrary choice

An associated type can describe a type chosen by an implementation, such as an iterator’s item type. A generic parameter can instead represent a type supplied at a particular use or implementation boundary. The distinction becomes important when callers need to state relationships between operations. Do not choose between the two solely by which spelling produces fewer angle brackets in a small example.

Start with a concrete question: does one implementation have one natural output type, or should the same implementation support several independently selected types? Then examine how callers constrain that choice. Our introductory Measure trait fixes its output to i32 to keep the dispatch explanation small. A production measurement API might use a domain-specific value type or an associated output, but that extra flexibility should solve a real caller requirement rather than anticipate every possible future extension.

Default methods should preserve the contract

A trait can provide a default method implementation using its other promised operations. This is useful when a common behavior can be expressed correctly for all conforming implementations. Implementers can override it when the API permits, but the override should still respect the documented semantics. A default is shared behavior, not a license for implementations to return incompatible meanings under the same method name.

Tests should exercise both ordinary implementations and any override whose behavior differs internally. Compare observable results, error propagation and ownership effects where relevant. Avoid making tests depend on whether a call used the default body or an override unless that distinction is itself part of a documented extension contract. Callers usually care that the operation means the same thing, not which source file contains the implementation.

Use an enum when the alternatives form a closed set

Traits are especially useful when implementations can be extended independently. An enum can be simpler when the set of alternatives is intentionally closed and callers need to handle each variant explicitly. A small application with two known source types may benefit from an enum and exhaustive matches, particularly if the variants carry different data that the caller must inspect.

Neither choice is a universal maturity level. Replacing every enum with trait objects can hide useful structural information, while forcing an open-ended extension system into one central enum can make independent additions awkward. Identify who is allowed to add alternatives and which operations need exhaustive decisions. That boundary often reveals the appropriate representation more clearly than an abstract debate about static and dynamic dispatch.

Argument impl Trait is not a trait object

A function parameter written with impl Trait can offer a concise way to accept a type satisfying a bound. It does not mean the caller passes a dynamically dispatched trait object. Keep the difference visible when explaining examples: a generic argument and a borrowed dyn Trait interface solve related but distinct API problems.

Return-position impl Trait also has its own contract: the function hides a concrete return type satisfying the bounds, rather than promising arbitrary unrelated concrete types on different branches. If runtime alternatives require different concrete values, consider an enum or an appropriate trait object. Do not change the spelling until the desired relationship between branches and callers is clear.

Review dyn compatibility when the interface grows

A small trait used through dyn can stop being dyn-compatible after its interface changes. Methods with their own generic type parameters or requirements involving a concrete Self need particular care. Consult the trait rules and compile a representative trait-object caller whenever adding methods; an internal implementation test alone may not expose the public compatibility break.

Sometimes a method can deliberately be restricted to sized implementers while the remaining interface stays usable through a trait object. Sometimes the design needs a separate extension trait. The appropriate fix depends on which callers need the new operation. Treat compatibility as part of the API promise, and keep a minimal external-style example that uses the public trait in the supported form.

Verify the promise at the caller boundary

Test the operation through the same interface real callers use. For a generic helper, exercise at least two meaningfully different implementations so the helper does not accidentally rely on an unstated concrete assumption. For a heterogeneous trait-object collection, verify the runtime-selected implementations produce the intended results and that their owners remain valid for the collection’s lifetime.

When performance becomes relevant, compare equivalent behavior and error handling under the same build settings. Record code-size and timing observations separately rather than assuming one determines the other. A useful design review ends with clear answers: what behavior is shared, what remains concrete, who owns the value, whether runtime variation is required, and how failures are represented. Those answers make traits an aid to understanding instead of an abstraction layer that callers must work around.

Explore the free Crabrix lessons →