Skip to content
← All Rust articles

Rust explainer

Choosing between generics, impl Trait, &dyn, Box<dyn> and enums

About 8 min to read. Allow extra time to work through examples.

Compare generic parameters, trait objects and enums by type relationships, ownership and extension needs. Includes a complete example of cloning boxed trait objects.

This article uses Rust. You can make it your practice mode to browse related content across the site.

Generics, trait objects and enums express different API contracts. Before comparing their speed, decide who chooses the concrete type, whether values of different types must share a collection, and who owns those values.

Generic parameters and argument-position impl Trait

A parameter such as x: &impl Draw introduces an anonymous generic type implementing Draw. A named parameter, fn draw<T: Draw>(x: &T), lets the function refer to that same T elsewhere in its signature or bounds. The compiler can monomorphize both forms for concrete types. Neither spelling guarantees a particular machine-code size or speed.

Check your understanding Do fn pair(a: &impl Draw, b: &impl Draw) and fn pair<T: Draw>(a: &T, b: &T) accept the same calls? Show answer

No. Each argument-position impl Draw introduces an independent type parameter. The first function can accept two different implementor types. The second requires both references to have the same referent type T.

Use the named parameter when that relationship is part of the contract. Merely comparing two values does not always require the same type; that depends on the operations and bounds you choose. Likewise, accepting two different types is useful only when the implementation supports it.

A caller can supply a named type argument explicitly, as in pair::<Circle>(...). An anonymous parameter cannot be supplied that way, but callers can still guide inference by annotating or constructing the argument with an explicit type. Inference is not automatically a dead end.

This example accepts different types for one operation and also shows how ?Sized lets a named generic parameter accept a trait object:

trait Draw {
    fn label(&self) -> &'static str;
}

struct Circle;
struct Square;
impl Draw for Circle {
    fn label(&self) -> &'static str { "circle" }
}
impl Draw for Square {
    fn label(&self) -> &'static str { "square" }
}

fn different(a: &impl Draw, b: &impl Draw) -> bool {
    a.label() != b.label()
}

fn label<T: Draw + ?Sized>(value: &T) -> &'static str {
    value.label()
}

fn main() {
    assert!(different(&Circle, &Square));
    assert_eq!(label::<Circle>(&Circle), "circle");
    let object: &dyn Draw = &Square;
    assert_eq!(label(object), "square");
}

Type parameters are Sized by default. Relaxing that bound with ?Sized makes the last call possible. A generic function instantiated with a trait object can still dispatch through that object's vtable; a generic signature does not guarantee that every operation inside is statically dispatched.

Return-position impl Draw is a different feature: the function chooses one hidden concrete return type for each generic instantiation. It does not let ordinary branches return unrelated implementors interchangeably. An enum can unify those branches, or a trait object can erase their types.

Compare the contracts

Form What it provides What to consider
Named generics or argument-position impl Trait Concrete types selected by the caller Type relationships, trait bounds and generated code
&dyn Draw A borrowed value behind a common interface The referent must live long enough for the borrow
Box<dyn Draw> Ownership of a value behind that interface Allocation, object lifetime and drop behavior
An enum such as AnyShape One type with a declared set of variants Variant sizes and how operations handle each variant

Vec<&dyn Draw> can contain references to different implementors without allocating each referent, although the vector itself can allocate. Vec<Box<dyn Draw>> owns its entries. Boxing a nonzero-sized value normally allocates; a zero-sized implementor does not require a payload allocation. An enum stores a selected variant inline, but its fields may themselves own allocations. None of these forms promises that the whole program is heap-free.

Public traits can allow downstream implementations, subject to visibility, coherence and any sealing pattern. Downstream code cannot add variants to your enum, but an enum variant could itself contain a trait object. These designs can be combined rather than treated as mutually exclusive camps.

A generic collection has one element type, but that type can be an enum or a trait-object handle. A generic logger can also accept a composite handler whose implementation forwards to several concrete handlers.

Borrowing and owning trait objects

Borrow when a caller retains ownership and the required lifetime is clear. Own when the API must retain or transfer responsibility for dropping a value. Storing a value in a struct does not itself require Box: the struct can own a concrete value or carry an explicit borrow lifetime.

Trait objects also have a lifetime bound for references inside their hidden value. In an ordinary return signature, Box<dyn Trait> often defaults to Box<dyn Trait + 'static>. This does not mean the box must live forever; it means the hidden value cannot depend on shorter-lived borrows. State a shorter bound when the object intentionally borrows data:

trait Message {
    fn text(&self) -> &str;
}

struct BorrowedMessage<'a>(&'a str);
impl Message for BorrowedMessage<'_> {
    fn text(&self) -> &str { self.0 }
}

fn message(text: &str) -> Box<dyn Message + '_> {
    Box::new(BorrowedMessage(text))
}

fn main() {
    let text = String::from("ready");
    let value = message(&text);
    assert_eq!(value.text(), "ready");
}
Check your understanding A logging library lets callers register different handler types. Which representation fits? Show answer

Vec<Box<dyn Handler>> is a practical choice when the logger owns handlers registered at runtime. A Vec<&dyn Handler> can fit a scoped logger whose caller owns the handlers. A generic Logger<H> can fit one handler type or a composite type that forwards to several handlers; it is not inherently incapable of logging to both a file and a network destination.

Shared cross-thread ownership may call for Arc<dyn Handler + Send + Sync>. Choose thread bounds from the operations the API permits. Arc does not synchronize mutable handler internals on its own.

Registration can separate allocation from repeated dispatch. Creating a box during registration does not mean every later handler call allocates; the handler's own implementation may still allocate.

Measure dispatch in its surrounding work

Generic code can specialize and inline operations for known concrete types. That can improve optimization, but extra instantiations can also increase build work and code size. Optimizers can merge or remove code, so there is no guaranteed one-function-copy-per-type accounting rule.

A trait-object call uses vtable dispatch unless optimization resolves the target more directly. Potential costs include indirect calls and missed inlining opportunities. An enum usually selects a variant with a match; variant size, branch predictability and the work inside each arm affect its performance. Neither approach has a fixed speed ratio or a universally zero-cost case.

Measure equivalent operations with representative type mixtures and inputs. State whether allocation and setup are inside the timed region, and record the compiler, optimization settings and machine. Include code size or build time if those are the reason you are considering a different API.

Adding an enum variant helps the compiler identify matches that need updating when they are exhaustive without a wildcard. Existing wildcard arms can absorb the new variant silently, so exhaustiveness is not a promise that every relevant decision will be revisited.

Borrowed boxes and downcasting

A function that only calls trait methods usually needs &dyn Trait, not &Box<dyn Trait>. The latter borrows the box handle and unnecessarily ties the API to that owner type. Clippy's borrowed_box can flag it. If a function must replace an owning handle, that is a different contract, such as taking &mut Box<dyn Trait> or accepting ownership.

Any supports runtime concrete-type inspection for types satisfying its 'static bound. It can suit heterogeneous registries, extension data or APIs that deliberately expose concrete values. A long chain of downcasts deserves a design check: if the cases are a known closed set, an enum may make handling and exhaustiveness clearer. Downcasting is not inherently incorrect, and those use cases are not limited to one approved list.

Clone the payload or share its ownership?

Check your understanding How can a collection of boxed trait objects support cloning? Show answer

First choose the semantics. Cloning an Rc<dyn Shape> or Arc<dyn Shape> produces another owner of the same value. Cloning a boxed payload through a custom method invokes that payload's Clone implementation; it may copy data or retain sharing in its fields. These operations are not interchangeable.

Clone itself is not dyn compatible: it requires Self: Sized and returns Self. Adding it as a supertrait would prevent dyn Shape. A dispatchable clone_box method can instead return a boxed trait object. For a closed enum, deriving Clone is an option when all fields satisfy the needed bounds.

Here is a complete boxed-clone implementation. The forwarding call uses as_ref() to invoke the method on the hidden payload:

trait Shape: ShapeClone {
    fn area(&self) -> u32;
}

trait ShapeClone {
    fn clone_box(&self) -> Box<dyn Shape>;
}

impl<T: Shape + Clone + 'static> ShapeClone for T {
    fn clone_box(&self) -> Box<dyn Shape> {
        Box::new(self.clone())
    }
}

impl Clone for Box<dyn Shape> {
    fn clone(&self) -> Self {
        self.as_ref().clone_box()
    }
}

#[derive(Clone)]
struct Rectangle { width: u32, height: u32 }
impl Shape for Rectangle {
    fn area(&self) -> u32 { self.width * self.height }
}

fn main() {
    let original: Box<dyn Shape> = Box::new(Rectangle { width: 3, height: 4 });
    let copied = original.clone();
    assert_eq!(copied.area(), 12);
    assert!(!std::ptr::eq(original.as_ref(), copied.as_ref()));
}

This version requires 'static implementors. A design that supports shorter-lived borrowed payloads needs corresponding lifetime bounds in its cloning interface. Cloning can allocate and can panic if the payload's Clone implementation panics.

Choose the contract first: type relationships, extension points, ownership, lifetimes and cloning semantics. Then measure the performance questions that remain for the implementation you actually need.

step 5 of 10

Trait Objects and Dispatch

Up next Monomorphisation: what generics compile to, and what that costs