Skip to content
Published on

Rust Traits, Generics, and Trait Objects

Authors

Introduction — Rust's Abstraction Tools

Object-oriented languages organize abstraction with inheritance and interfaces. Rust has no class inheritance. Instead it gets code reuse and polymorphism from two axes: traits and generics. Understand these two and the signatures in Rust libraries suddenly start to read — what a notation like T: Display + Clone means, why impl Iterator and Box<dyn Iterator> differ.

The key intuition is this. A trait defines "what a type can do," and a generic writes "code that requires that ability." And the highlight of this post is that there are two ways to carry out that requirement (static dispatch and dynamic dispatch). Let's build up one piece at a time.

Traits — Interfaces, and More

A trait is a set of methods a type can implement. So far this resembles interfaces in Java and C#, or Go's interfaces.

trait Summary {
    fn summarize(&self) -> String; // signature only (no body)

    fn preview(&self) -> String {   // provide a default implementation
        format!("{}...", &self.summarize()[..5.min(self.summarize().len())])
    }
}

struct Article {
    title: String,
    body: String,
}

impl Summary for Article { // implement Summary for Article
    fn summarize(&self) -> String {
        format!("{}: {}", self.title, self.body)
    }
    // preview uses the default implementation as-is
}

trait Summary defines the contract, and impl Summary for Article makes Article satisfy it. That you can provide a default implementation (preview) is a step beyond a plain interface (similar to Java's default methods).

There are two decisive differences from interfaces.

First, where a type is defined and where a trait is implemented are separate. In Java you have to write implements Comparable up front when you declare the class. In Rust you can implement a trait on a type that already exists (including one you didn't create) later on. You can even implement your own trait on the standard library's i32.

Second, that freedom comes with a constraint: the orphan rule. To implement a trait, either the trait or the type must be owned by your crate. Implementing someone else's trait on someone else's type is forbidden. The reason is coherence: if two crates each implemented Display for Vec, there'd be a conflict over which to use. This rule guarantees "at most one implementation per (trait, type) pair."

Generics and Trait Bounds

Now let's write code that requires a trait. A generic function says "I take any type T," but usually it needs T to have a particular ability. That requirement is a trait bound.

use std::fmt::Display;

// T can be any type that implements Display
fn announce<T: Display>(item: T) {
    println!("announcement: {item}"); // thanks to Display, {} formatting works
}

fn main() {
    announce(42);              // i32 implements Display
    announce("hello");         // &str implements Display too
    announce(3.14);            // f64 as well
}

<T: Display> is the bound saying "T must implement Display." Without it, you couldn't print {item} inside announce (the compiler refuses because it "doesn't know T is Display"). A bound tells the compiler — and the reader — what the generic code is allowed to assume about T.

Multiple bounds are joined with +, and when there are many, a where clause factors them out cleanly.

use std::fmt::{Debug, Display};

// inline bounds — when short
fn show<T: Display + Clone>(x: T) { /* ... */ }

// where clause — more readable with many bounds
fn process<T, U>(t: T, u: U) -> String
where
    T: Display + Clone,
    U: Debug + Default,
{
    format!("{t} {u:?}")
}

Here's one fundamental difference from Java generics. Java generics use type erasure — type information is wiped at runtime, and bounds are mostly handled with casts. Rust generics are monomorphized. For announce(42) and announce("hello"), the compiler generates separate functions, one for i32 and one for &str. So even though it's generic, there's zero runtime cost. This is "static dispatch," coming up next.

Static Dispatch — Zero-Cost Polymorphism

As a result of monomorphization, a generic function call is statically dispatched. Which implementation to call is decided at compile time, code specialized to each type is generated, and the function may even be inlined. There's no runtime cost to figure out "which method?"

The idiomatic way to take a trait as an argument is the impl Trait syntax. It's syntactic sugar for a generic.

use std::fmt::Display;

// The two below are effectively identical (impl Trait is shorthand for a generic)
fn v1(item: impl Display) { println!("{item}"); }
fn v2<T: Display>(item: T) { println!("{item}"); }

You can also use impl Trait in return position, which is especially useful. When returning a concrete type that's hard to name — like an iterator or a closure — you only have to state "some concrete type that satisfies this trait."

// The real return type is a complex Map<...>, but we can hide it
fn evens(max: u32) -> impl Iterator<Item = u32> {
    (0..max).filter(|n| n % 2 == 0)
}

fn main() {
    for n in evens(10) {
        print!("{n} "); // 0 2 4 6 8
    }
}

The trade-off of static dispatch is code size. Monomorphization stamps out separate code per type, so the binary can grow (code bloat). But in most cases where speed is the priority, this trade-off is a bargain.

Dynamic Dispatch — dyn Trait Objects

Sometimes, though, you want to mix different types in one collection — like "a list of drawable shapes." Even if circles, squares, and triangles all implement a Draw trait, a generic Vec<T> can hold only one concrete type. What you need here is a trait object, written dyn Trait.

trait Draw {
    fn draw(&self) -> String;
}

struct Circle;
struct Square;
impl Draw for Circle { fn draw(&self) -> String { "○".into() } }
impl Draw for Square { fn draw(&self) -> String { "□".into() } }

fn main() {
    // Different types in one Vec! (boxed, as trait objects)
    let shapes: Vec<Box<dyn Draw>> = vec![
        Box::new(Circle),
        Box::new(Square),
        Box::new(Circle),
    ];

    for s in &shapes {
        print!("{} ", s.draw()); // calls the actual type's draw at runtime
    }
}

Box<dyn Draw> is "some type on the heap that implements Draw." Calling s.draw() here triggers dynamic dispatch. At compile time we don't know s's real type, so it's decided at runtime.

The mechanism is a virtual function table (vtable). A Box<dyn Draw> is actually two pointers: one points at the real data (the data pointer), and one points at the vtable holding the addresses of that type's Draw method implementations. s.draw() looks up draw's address in the vtable and jumps there. It's the same mechanism as C++'s virtual functions. This is why dyn Trait is called a "fat pointer" — it's twice the size of an ordinary pointer.

Static vs Dynamic — Which and When

Put the two kinds of dispatch side by side and the trade-off is clear.

  • Static dispatch (generics / impl Trait): resolved at compile time. Inlines and optimizes well. Zero runtime overhead. In exchange, code is generated per type so the binary can grow, and you can't hold a heterogeneous collection.
  • Dynamic dispatch (dyn Trait): resolved at runtime via a vtable. You can hold heterogeneous types in one collection, and the binary stays small (just one implementation). In exchange, each call pays a vtable lookup (an indirect call, hard to inline).

The practical conclusion is this. Default to static dispatch. In most cases it's faster and more natural. Use dynamic dispatch when you need a heterogeneous collection (the shape list above), when you must avoid code bloat, or when the type is only determined at runtime (like plugins). In most applications the vtable lookup cost is negligible, so you rarely need to avoid dyn for performance — choose based on expressiveness and code structure.

One constraint: not every trait can become a trait object. It must satisfy the object-safety rules (roughly, methods must not be generic and must not return Self by value). Use a trait that breaks these rules with dyn and the compiler tells you why.

Associated Types — Types Attached to a Trait

A trait can carry not only methods but also associated types. The canonical example is the standard Iterator.

trait Iterator {
    type Item; // associated type: the type of what this iterator yields

    fn next(&mut self) -> Option<Self::Item>;
}

struct Counter { count: u32 }

impl Iterator for Counter {
    type Item = u32; // fix Item to u32 here

    fn next(&mut self) -> Option<u32> {
        if self.count < 3 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

type Item is a "blank type" the trait requires, and when you implement it you fill it in with type Item = u32. That makes next return Option<u32>.

A natural question arises. "Couldn't this just be a generic Iterator<Item>?" It could, but there's a difference. An associated type fixes the implementation to one per type. Counter can be exactly one kind of iterator, with Item = u32. If it were a generic parameter, Counter could be Iterator<u32> and Iterator<String> at the same time, making type inference ambiguous. Use an associated type to express "this relationship is unique for this type," and a generic parameter to express "several relationships are possible." The element type an iterator yields is naturally just one, so an associated type is the right fit.

Blanket impls — For Every Type That Meets a Condition

Where traits' power explodes is the blanket implementation — "for every type that implements one trait, automatically implement another trait."

A real example from the standard library is striking. The ToString trait (the .to_string() method) is defined like this.

// The actual standard library (simplified)
impl<T: Display> ToString for T {
    fn to_string(&self) -> String {
        // build a string using Display's formatting
        format!("{self}")
    }
}

Savor what this one snippet does. "Every type T that implements Display automatically implements ToString too." So when you implement only Display on a new type, .to_string() comes along for free. No one implements ToString by hand per type. A single conditional blanket impl covers the entire ecosystem.

A blanket impl is also an idiom for expressing "combinations of abilities." impl<T: A + B> C for T declares the rule "a type that has both A and B is also C." When traits, generics, and blanket impls interlock, you assemble remarkably expressive abstractions with no inheritance hierarchy.

derive — Automatic Trait Implementations

Finally, a convenience you use every day in practice. Many standard traits can be implemented automatically with the #[derive(...)] attribute. The compiler looks at the field structure and generates the obvious implementation for you.

#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = p1.clone();          // thanks to Clone
    println!("{p1:?}");           // thanks to Debug: Point { x: 1, y: 2 }
    println!("{}", p1 == p2);     // thanks to PartialEq: true
}

One line of #[derive(Debug, Clone, PartialEq)] generates implementations of three traits: Debug (debug printing), Clone (duplication), and PartialEq (equality comparison). Boilerplate that would be dozens of lines by hand is generated recursively, field by field, by the compiler. Commonly derived traits include Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, and Ord.

The mechanism behind derive is a procedural macro. In other words, it's not magic — just a macro that generates code at compile time. That's why you can attach derive macros to your own traits too (for example, serde's #[derive(Serialize)]), and this is a major pillar holding up the ergonomics of the Rust ecosystem.

Learning It With Your Hands

Trait bounds, static vs dynamic dispatch, and associated types get comfortable once you develop a feel for reading signatures. In the Rust Learning Lab, defining a trait and putting bounds on a generic function, then writing the same code once with impl Trait (static) and once with Box<dyn Trait> (dynamic) to feel the difference firsthand, makes the complex signatures in library docs far easier to read.

Wrapping Up

Rust offers rich abstraction with no inheritance. A trait defines an ability, generics and trait bounds require that ability, and static dispatch carries it out at zero cost. When you need to gather different types together, a dyn trait object gives you flexibility through dynamic dispatch via a vtable. Associated types express a type relationship attached to a trait, blanket impls cover every type that meets a condition at once, and derive produces the obvious implementations for free.

The moment you understand how these pieces interlock, the signatures in Rust code start to read as information rather than noise. See fn foo<T: Trait>(...) and you immediately know "this function requires this ability of T"; see Box<dyn Trait> and you know "here we handle heterogeneous types with runtime dispatch." That's the sign you've got Rust's abstraction in hand.

References