Tech Wiki

TOPICSSERIES

[Rust Zero to Production 14] Rust Generics and Traits: Reuse Without Losing Clarity

Generics, trait objects, and impl Trait all let Rust code apply shared behavior to multiple types. They are not interchangeable spellings. The choice depends on when the concrete type becomes known, whether implementations must be selected at runtime, and how much type information the caller should see.

The example draws two boundaries. A repository has one concrete implementation per call, so generic code connects it to the application. Checkers need to hold different implementations in one collection, so they use trait objects. impl Trait is reserved for a short generic parameter and a hidden concrete return type. That is enough abstraction to explain the tradeoffs without wrapping a small program in extra layers.

1. Trait Example

This example uses Rust 2024. It has no external dependencies.

[package]
name = "article-14-generics-traits"
version = "0.1.0"
edition = "2024"
publish = false

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
all = "warn"
pedantic = "warn"

These commands check formatting, all targets, Clippy with warnings denied, tests, and the binary:

cd examples/article-14-generics-traits
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

2. Trait Contracts

Repository states the operations the application needs, not how storage works. The current implementation uses Vec<Endpoint>, but callers only know how to add and list endpoints. Checker likewise exposes the common contract for an endpoint check.

pub trait Repository {
    fn add(&mut self, endpoint: Endpoint);
    fn all(&self) -> &[Endpoint];
}

pub trait Checker {
    fn label(&self) -> &'static str;
    fn check(&self, endpoint: &Endpoint) -> CheckResult;
}

Defining a trait does not mean every use needs dyn. A function that works with one implementation at a time can use a type parameter and trait bound. A collection that owns different checker implementations needs one static element type. That is the job of dyn Checker.

3. Generics and Concrete Types

run_static declares repository type R and checker type C. Each call resolves both parameters to one concrete type.

pub fn run_static<R, C>(repository: &R, checker: &C) -> Vec<CheckResult>
where
    R: Repository,
    C: Checker,
{
    enabled_endpoints(repository)
        .map(|endpoint| checker.check(endpoint))
        .collect()
}

The compiler monomorphizes generic code. It generates code for the concrete types used by the program, which gives this call static dispatch. The narrow, defensible claim is that the method target is known at compile time. That does not prove the generic version is always faster. Inlining, code size, instruction-cache behavior, optimization decisions, and actual inputs all matter.

The repository boundary is generic for a design reason, not as a performance slogan. This program never mixes repository implementations at runtime. A test may call the function with InMemoryRepository, and another caller could use a different implementation, but each invocation still has one concrete repository type. The signature expresses exactly that amount of flexibility.

4. Trait Objects and Runtime Implementations

Checkers have a different requirement. The program stores an HTTPS checker and a name-length checker in one list, then runs both. Vec<HttpsChecker> cannot contain a NameLengthChecker, so the code erases the concrete pointee type behind dyn Checker.

pub fn run_dynamic<R>(
    repository: &R,
    checkers: &[Box<dyn Checker>],
) -> Vec<CheckResult>
where
    R: Repository,
{
    enabled_endpoints(repository)
        .flat_map(|endpoint| checkers.iter().map(move |checker| checker.check(endpoint)))
        .collect()
}

A trait object is used behind a pointer such as &dyn Checker or Box<dyn Checker>, rather than as a bare dyn Checker value. Such a pointer carries a pointer to the value and virtual method table (vtable) information for finding that implementation's methods. Method calls use dynamic dispatch through the vtable at runtime. The example uses Box because the vector owns its checkers. A borrowed, short-lived list could use &dyn Checker instead.

Dynamic dispatch adds an indirect call, and each Box in this example allocates a checker on the heap. Those facts do not establish a meaningful slowdown. This crate contains no benchmark and its checkers do no real work. In a service where a checker performs network I/O, dispatch may be a small fraction of total time, but that too remains an assumption until measured.

5. Dyn Compatibility and Trait Objects

Not every trait can be written as dyn Trait. The current Reference calls the rules dyn compatibility; older material often calls the same concept object safety. The base trait and its supertraits must be dyn compatible, and the trait cannot require Self: Sized. Dispatchable methods need an allowed receiver and cannot have their own type parameters. Associated constants, generic associated types, and methods that return Self also face restrictions.

The vtable model explains the constraint. check(&self, endpoint: &Endpoint) -> CheckResult has one call shape even when the concrete checker type is unknown. A method such as fn convert<T>(&self, value: T) needs code specialized for each T, so one vtable entry cannot represent every call. If a method need not be called through a trait object, a where Self: Sized bound can explicitly exclude it from object dispatch.

Both Checker methods take &self and use neither method-level generics nor a Self return, so Box<dyn Checker> is valid. Do not weaken every trait preemptively to gain object compatibility. Account for these constraints only when runtime polymorphism is an actual requirement.

6. Argument-Position impl Trait

The seeding function does not need to name its repository type anywhere else. impl Repository keeps that simple parameter short.

pub fn seed_repository(repository: &mut impl Repository) {
    repository.add(Endpoint::new(
        "api",
        "https://api.example.com/health",
        true,
    ));
}

In argument position, impl Repository acts like an anonymous type parameter and uses static dispatch. It is not shorthand for &mut dyn Repository. An explicit <R: Repository> is preferable when the same type appears in several parameters or must be named in a return type or where clause.

The forms are not identical for source compatibility. The Reference notes that a caller can explicitly supply a named type parameter with syntax such as function::<ConcreteType>(...); an impl Trait parameter has no such name. Changing between the forms in a public API can therefore change the number of explicit generic arguments accepted at call sites.

7. Return-Position impl Trait

In return position, impl Trait has a different role. The function chooses one concrete type that implements the trait without exposing its name to callers.

pub fn enabled_endpoints(
    repository: &impl Repository,
) -> impl Iterator<Item = &Endpoint> {
    repository.all().iter().filter(|endpoint| endpoint.is_enabled())
}

#[must_use]
pub fn default_checker() -> impl Checker {
    HttpsChecker
}

enabled_endpoints hides a long type composed from slice::Iter and Filter. Callers only rely on its Iterator<Item = &Endpoint> contract. default_checker hides the fact that its concrete return type is HttpsChecker. Neither function chooses an arbitrary implementation for each call as a trait object could. Every return path in a given function must resolve to the same hidden concrete type selected by the compiler.

A function that must return either HttpsChecker or NameLengthChecker based on runtime state cannot do so with a plain -> impl Checker. For a closed set, an enum can hold the alternatives. If open-ended runtime variation is required, Box<dyn Checker> may fit. Boxing every return value from the outset would solve a requirement the program may not have.

8. Right-Sized Abstractions

Traits can overwhelm a small program when every struct gets an abstraction layer. This example isolates only two plausible points of variation. Storage may move beyond its in-memory implementation, and the checker list demonstrably mixes two concrete types. Endpoint and CheckResult remain ordinary concrete structs.

Use these tests when choosing a boundary:

  • Use generics and trait bounds when each call has one concrete implementation and type relationships should remain visible to the compiler.
  • Consider a trait object when one collection must hold different implementations or the implementation is selected at runtime.
  • Use argument-position impl Trait when the parameter's type name is not needed elsewhere in the signature.
  • Use return-position impl Trait to hide the name of one concrete return type.
  • Keep the concrete type when there is one implementation and no need for substitution, a test double, or a public behavior contract.

9. Trait Check

All commands should pass on stable rustc 1.98.1, Cargo 1.98.1, and Rust 2024. The first test run contains 6 tests. The binary and documentation test runs that follow each contain 0 tests.

running 6 tests
test tests::disabled_endpoints_are_not_checked ... ok
test tests::impl_iterator_hides_the_filter_type ... ok
test tests::repository_trait_keeps_storage_behind_a_boundary ... ok
test tests::static_dispatch_uses_one_checker_type ... ok
test tests::static_signature_preserves_the_generic_boundary ... ok
test tests::trait_objects_mix_checker_types_in_one_collection ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The exact stdout from cargo run --quiet is:

api https: pass
api name-length: fail

The first line comes from HttpsChecker; the second comes from NameLengthChecker in the same Vec<Box<dyn Checker>>. The repository remains the concrete InMemoryRepository across its generic boundary. Static and dynamic dispatch do not need to be one project-wide policy. Pick separately for each axis of change.

Full source code

The complete runnable source for this article is available in the Chapter 14 project on GitHub.

Sources


Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.