Skip to content

Rust Trait Objects and Dispatch: dyn Trait vs impl Trait

Use generics when an operation can keep its concrete implementation type, and a trait object when values with different implementation types must share one runtime interface. Argument-position impl Trait is generic syntax; return-position impl Trait hides one concrete type rather than creating a mixed-type container.

Prerequisites and outcome

Complete lifetimes and borrowed APIs. You will compare three calling forms, build a borrowed heterogeneous collection, and recognise restrictions on dyn-compatible traits. The examples use fixture readings; no sensor or performance measurement is needed.

Choose the Rust interface by what must vary

Form Concrete implementation choice Useful when
T: Read Caller chooses T; each instantiation keeps that type Generic algorithms and relationships between arguments
Argument impl Read Caller supplies an anonymous generic type One parameter needs a simple behaviour bound
Return impl Read Function hides one concrete return type Exposing behaviour without naming the implementation
&dyn Read<Value = i32> A borrowed trait object carries an implementation at runtime Mixed concrete implementations behind one interface

The associated type is specified in the object type because the caller still needs to know the read method's return type. This example is deliberately narrower than the previous Source trait: Read has no associated constant, so it can be used as a trait object.

Build the complete dispatch comparison

cargo new pi_dispatch
cd pi_dispatch

Keep edition = "2024" in Cargo.toml and replace src/main.rs with:

trait Read {
    type Value;
    fn read(&self) -> Option<Self::Value>;
}

struct Fixed(i32);

impl Read for Fixed {
    type Value = i32;

    fn read(&self) -> Option<Self::Value> {
        Some(self.0)
    }
}

struct Unavailable;

impl Read for Unavailable {
    type Value = i32;

    fn read(&self) -> Option<Self::Value> {
        None
    }
}

struct Borrowed<'a>(&'a i32);

impl Read for Borrowed<'_> {
    type Value = i32;

    fn read(&self) -> Option<Self::Value> {
        Some(*self.0)
    }
}

fn read_generic<T: Read<Value = i32> + ?Sized>(source: &T) -> Option<i32> {
    source.read()
}

fn read_impl(source: &impl Read<Value = i32>) -> Option<i32> {
    source.read()
}

fn read_dynamic(source: &dyn Read<Value = i32>) -> Option<i32> {
    source.read()
}

fn make_fixed(value: i32) -> impl Read<Value = i32> {
    Fixed(value)
}

// The Box owns the Borrowed wrapper, not the integer it points to.
fn boxed_borrow<'a>(value: &'a i32) -> Box<dyn Read<Value = i32> + 'a> {
    Box::new(Borrowed(value))
}

fn main() {
    let fixed = Fixed(46_700);
    let unavailable = Unavailable;
    println!("generic={:?}", read_generic(&fixed));
    println!("impl argument={:?}", read_impl(&fixed));
    println!("dynamic={:?}", read_dynamic(&fixed));

    // No Box is needed to borrow differently typed, existing values.
    let sources: [&dyn Read<Value = i32>; 2] = [&fixed, &unavailable];
    for source in sources {
        println!("mixed={:?}", read_dynamic(source));
    }

    let hidden = make_fixed(0);
    println!("opaque return={:?}", read_generic(&hidden));

    let value = -500;
    let boxed = boxed_borrow(&value);
    println!("boxed borrow={:?}", read_dynamic(boxed.as_ref()));
}

#[cfg(test)]
mod tests {
    use super::{Fixed, Read, Unavailable, boxed_borrow, make_fixed};
    use super::{read_dynamic, read_generic, read_impl};

    #[test]
    fn concrete_calls_agree_for_a_present_value() {
        let source = Fixed(46_700);
        assert_eq!(read_generic(&source), Some(46_700));
        assert_eq!(read_impl(&source), Some(46_700));
        assert_eq!(read_dynamic(&source), Some(46_700));
    }

    #[test]
    fn concrete_calls_agree_for_absence() {
        let source = Unavailable;
        assert_eq!(read_generic(&source), None);
        assert_eq!(read_impl(&source), None);
        assert_eq!(read_dynamic(&source), None);
    }

    #[test]
    fn mixed_objects_preserve_each_implementation() {
        let fixed = Fixed(0);
        let unavailable = Unavailable;
        let sources: [&dyn Read<Value = i32>; 2] = [&fixed, &unavailable];
        assert_eq!(read_dynamic(sources[0]), Some(0));
        assert_eq!(read_dynamic(sources[1]), None);
    }

    #[test]
    fn relaxed_sized_bound_accepts_an_object() {
        let fixed = Fixed(7);
        let object: &dyn Read<Value = i32> = &fixed;
        assert_eq!(read_generic(object), Some(7));
    }

    #[test]
    fn opaque_return_exposes_the_promised_trait() {
        let source = make_fixed(0);
        assert_eq!(source.read(), Some(0));
    }

    #[test]
    fn boxed_object_can_borrow_local_data() {
        let value = -500;
        let source = boxed_borrow(&value);
        assert_eq!(source.read(), Some(-500));
    }

    #[test]
    fn borrowed_object_does_not_consume_the_integer() {
        let value = 60_000;
        {
            let source = boxed_borrow(&value);
            assert_eq!(source.read(), Some(60_000));
        }
        assert_eq!(value, 60_000);
    }
}
1
2
3
4
cargo check
cargo test
cargo fmt --check
cargo run --quiet

Expected output:

1
2
3
4
5
6
7
generic=Some(46700)
impl argument=Some(46700)
dynamic=Some(46700)
mixed=Some(46700)
mixed=None
opaque return=Some(0)
boxed borrow=Some(-500)

There are seven tests. Zero is a present value, not absence, regardless of the calling form. The object array borrows its sources; they must remain valid while those references are used.

Static and dynamic dispatch do not choose ownership

The generic function is instantiated for its concrete uses. The impl-argument form is also generic; it does not erase the type through a vtable. Naming T is useful when the same type must appear in several parameters or the return type; separate impl Trait parameters do not assert that their types are equal. Changing public signatures can also change which explicit generic arguments callers may supply.

A trait-object pointer carries access to the value and its implementation's vtable; object calls use dynamic dispatch. This is a language-level description, not a promise of a particular ABI or machine instruction after optimisation. See the trait-object reference.

Both our generic and dynamic functions borrow. Passing a value by a generic T could instead move it, while Box owns its contained value. The keyword dyn neither forces a heap allocation nor makes data thread-safe. &dyn Read in the array needs no boxing; the boxed_borrow function uses Box only to demonstrate an owned wrapper. Box's resource behaviour is taught later.

dyn Read<Value = i32> is dynamically sized. The ?Sized bound allows read_generic to accept an object through &T; removing it restores the implicit Sized requirement. The impl-argument function shown here retains that requirement, so the two signatures do not accept exactly the same set of inputs.

Opaque return types keep one concrete type

make_fixed returns Fixed while promising only Read. Callers can use the promised trait but cannot treat the result as a publicly exposed Fixed or access its tuple field. Different branches in this function cannot return unrelated concrete types under one impl Trait return.

The impl Trait reference distinguishes argument parameters from opaque returns. In Rust 2024, opaque returns automatically capture in-scope generic parameters, including lifetimes. A use<...> bound can precisely control permitted captures where supported. Our factory takes an owned integer and needs no borrowed capture; borrowing factories and edition differences require attention rather than assuming impl Trait means 'static.

If runtime branches must return different implementations, use a suitable enum to keep a closed set of alternatives, or a boxed dyn-compatible interface when runtime type erasure fits the API. Neither is universally the faster or simpler choice. We make no performance comparison here.

Dyn compatibility is a trait design constraint

Our Read has an ordinary associated type fixed in the object type, and a method with an &self receiver. That is sufficient for this example. The Source trait from lesson 12 has an associated constant and is not dyn-compatible as written.

Other restrictions include a Sized supertrait, generic associated types, and dispatchable methods with type parameters or unsupported uses of Self. A method that needs concrete Self can sometimes be marked where Self: Sized; it then cannot be called on the trait object. Generic methods, opaque returns and async methods must be checked against the actual rules, not accepted just because other methods work. Consult dyn compatibility; older material calls it object safety.

Auto-trait bounds such as Send or Sync can further restrict an object, but the implementation must satisfy them. A trait object cannot simply combine arbitrary unrelated non-auto traits with plus; a named supertrait can expose a combined interface. Concurrency requirements are covered in their own lessons.

Object lifetimes are separate from pointer ownership

Box<dyn Read<Value = i32> + 'a> allows the erased implementation to contain a reference valid for 'a. The Box owns Borrowed, while the local value remains owned by main. Dropping the Box releases its wrapper, not the integer's owner.

Outside expression inference, a plain Box commonly defaults its object lifetime to 'static unless another rule supplies a bound. That default is not the lifetime of the Box variable and does not forbid ordinary destruction of an owned implementation. A reference to a trait object has different defaulting rules. Spell the bound when exposing borrowed implementations; see object lifetime defaults.

Deliberately failing: two hidden concrete return types

In a separate scratch project, replace src/main.rs with:

trait Read {
    fn read(&self) -> Option<i32>;
}

struct Fixed(i32);
impl Read for Fixed {
    fn read(&self) -> Option<i32> {
        Some(self.0)
    }
}

struct Unavailable;
impl Read for Unavailable {
    fn read(&self) -> Option<i32> {
        None
    }
}

fn choose(available: bool) -> impl Read {
    if available {
        Fixed(46_700)
    } else {
        Unavailable
    }
}

fn main() {
    println!("{:?}", choose(true).read());
}

cargo check reports E0308. Both types implement Read, but an opaque return is not a runtime union. A repair is to return Box, boxing both branches; here both concrete implementations own all their data.

Exercises and troubleshooting

  1. Remove ?Sized from read_generic and call it with a &dyn Read: expect E0277. Restore it when borrowing unsized implementations is intended.
  2. Try an array [Fixed(0), Unavailable] without trait-object references: expect E0308 because array elements need one type.
  3. Add an associated constant with a default to Read: expect E0038 at the trait-object uses. Remove it from this runtime interface or redesign the interface; do not delete useful constants from unrelated traits merely to make everything dyn-compatible.
  4. Remove + 'a from boxed_borrow's return type: expect a lifetime error because the default object bound cannot accept this local borrow. Restore the explicit lifetime; do not leak the integer.
  5. Repair choose with Box and Box::new in both branches. Test both true and false: expect Some(46700) and None.
  6. Give make_fixed two branches that both construct Fixed with different integers: it remains a valid impl Read return because the concrete type is unchanged.

When a compiler error says a trait is not dyn-compatible, inspect its associated items. For mismatched opaque returns, inspect concrete branch types. For a lifetime error, inspect the erased implementation's borrows and the object bound, not only whether the outer pointer owns its wrapper.

Verification and next step

On October 10, 2026, the lesson was verified on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, all seven tests, formatting and debug/release output comparisons passed. The boxed branch repair returned both present and missing results; same-concrete-type opaque branches also passed. Mixed concrete array elements, incompatible opaque returns, a missing relaxed Sized bound, an associated constant on the object trait and an omitted borrowed-object lifetime failed as expected (E0308, E0277, E0038 and a lifetime diagnostic as applicable). No live sensor or dispatch benchmark is claimed.

Next: closures and function pointers, choosing capture ownership and callable bounds.

Previous: lifetimes and borrowed APIs · Course overview

Donate