Rust Deep Dives #5: Rust's Trait Patterns — When to Be Generic, When to Be Dynamic
Post 5 of 8 in Rust Deep Dives. Companion series: Rust Patterns That Matter.
Previous: #4: String Types | Next: #6: Serde Patterns
Most Rust programmers learn traits early. You define a trait, you implement it for a type, you call the methods. That part is straightforward. The hard part is designing with traits -- knowing which of the three dispatch mechanisms to use, when to reach for associated types versus generic parameters, and how patterns like extension traits and sealed traits let you build APIs that are both flexible and safe.
This post is about those design decisions. Not the syntax of traits -- the
strategy. If you've been defaulting to dyn Trait everywhere
because it feels familiar from other languages, or avoiding generics because
the angle brackets are intimidating, this is for you.
The Three Ways to Use a Trait -- and When Each One Wins
Rust gives you three ways to use a trait in a function signature, and each one makes a fundamentally different trade-off. Let's use the same trait for all three so the comparison is concrete.
trait Compress {
fn compress(&self, data: &[u8]) -> Vec<u8>;
fn name(&self) -> &str;
}
struct Gzip;
struct Zstd { level: i32 }
impl Compress for Gzip {
fn compress(&self, data: &[u8]) -> Vec<u8> { /* gzip logic */ todo!() }
fn name(&self) -> &str { "gzip" }
}
impl Compress for Zstd {
fn compress(&self, data: &[u8]) -> Vec<u8> { /* zstd logic */ todo!() }
fn name(&self) -> &str { "zstd" }
}
Now let's write a function that uses Compress in all three ways.
T: Trait -- Static Dispatch with Generics
fn compress_file<C: Compress>(compressor: &C, path: &Path) -> Result<Vec<u8>, io::Error> {
let data = std::fs::read(path)?;
println!("Compressing with {}", compressor.name());
Ok(compressor.compress(&data))
}
When you write C: Compress, the compiler monomorphizes the
function -- it generates a separate copy for each concrete type you call it
with. compress_file::<Gzip> and
compress_file::<Zstd> are two entirely different functions
in the compiled binary. The trait method calls are resolved at compile time
and can be fully inlined.
If you've used C, think of it like writing a separate function for each type by hand, except the compiler does the copy-paste for you. Zero overhead at runtime, but the binary grows with each concrete type, and compile times increase because the compiler is doing more work.
When to use this: performance-critical paths, when you know the set of types at compile time, and when you want the compiler to optimize across the trait boundary. This is your default choice for library code that needs to be fast.
impl Trait -- Static Dispatch, Anonymous
// In argument position: syntactic sugar for the generic above
fn compress_file(compressor: &impl Compress, path: &Path) -> Result<Vec<u8>, io::Error> {
let data = std::fs::read(path)?;
Ok(compressor.compress(&data))
}
// In return position: "I'm returning one specific type, but I won't say which"
fn default_compressor() -> impl Compress {
Zstd { level: 3 }
}
In argument position, impl Compress is exactly the same as a
generic parameter. The compiler still monomorphizes. It's just shorter to
write when you don't need to name the type parameter (you'd need the name if,
say, two arguments had to be the same type, or if you needed to
use the type in a where clause).
In return position, it's genuinely different. The function returns one concrete type, but the caller can't name it. This is how you return closures and iterator chains -- types the compiler generates that have no user-visible name. The caller can only use methods from the trait.
// You can't do this with dyn Trait without a Box:
fn make_filter(threshold: f64) -> impl Fn(f64) -> bool {
move |x| x > threshold
}
// Or return a complex iterator chain:
fn valid_scores(data: &[String]) -> impl Iterator<Item = f64> + '_ {
data.iter()
.filter_map(|s| s.parse::<f64>().ok())
.filter(|&x| x >= 0.0 && x <= 100.0)
}
When to use this: simple function signatures where a full generic parameter feels heavy, and return positions where you need to hand back closures, iterators, or async futures without boxing them.
dyn Trait -- Dynamic Dispatch
fn compress_file(compressor: &dyn Compress, path: &Path) -> Result<Vec<u8>, io::Error> {
let data = std::fs::read(path)?;
Ok(compressor.compress(&data))
}
With dyn Compress, there's only one compiled version of the
function. The method calls go through a vtable -- a pointer to a table of
function pointers, just like virtual methods in C++. The compiler can't
inline across the trait boundary, so there's a small runtime cost per call.
The key thing dyn Trait gives you that generics can't:
heterogeneous collections. You can put different concrete types in the same
container.
// A vec of mixed compressor types -- impossible with generics
let compressors: Vec<Box<dyn Compress>> = vec![
Box::new(Gzip),
Box::new(Zstd { level: 5 }),
];
for c in &compressors {
println!("Available: {}", c.name());
}
A dyn Trait value always lives behind a pointer --
&dyn Trait, Box<dyn Trait>, or
Arc<dyn Trait>. That pointer is actually a "fat pointer":
a pointer to the data plus a pointer to the vtable. This is why
dyn Trait is unsized -- the compiler doesn't know at compile
time how big the concrete type behind it is.
Not every trait can be used as dyn Trait. The trait must be
"object safe," which means its methods can't use Self as a
return type, can't have generic type parameters, and a few other
restrictions. If you try to use a non-object-safe trait as dyn,
the compiler will tell you exactly which method violates the rules.
When to use this: plugin systems, heterogeneous collections, callback registries, and anywhere the concrete type isn't known until runtime. Also useful in large codebases where you want to reduce compile times -- one compiled function instead of N monomorphized copies.
The Decision Framework
When you're staring at a function signature and wondering which form to use, ask these questions in order:
Do you need a collection of mixed types? If yes, you need
dyn Trait. Generics can't give you a
Vec<different types>. That said, if the set of types is
small and known at compile time, consider
enum dispatch instead --
it's faster and doesn't require heap allocation.
Do you need maximum performance? Use T: Trait or
impl Trait. The compiler can inline everything and optimize
across the trait boundary. For hot loops processing millions of items, this
matters.
Is the set of types open or closed? If users of your library will define
their own types that implement the trait, dyn Trait is the
natural choice for storing them. If you control all the implementations,
generics or enums give you better performance.
None of this is absolute. Plenty of programs use dyn Trait
everywhere and are fast enough. The vtable overhead is a pointer
indirection -- nanoseconds. Profile before you optimize. But knowing the
trade-offs means you make the right call when it matters.
Associated Types vs Generic Parameters
Here's a question that trips up a lot of people: should your trait have
type Output or should it be Trait<T>? Both
let you parameterize behavior by type. The difference is how many times a
given type can implement the trait.
The rule of thumb: if there's exactly one sensible implementation per type, use an associated type. If a type could implement the trait multiple times with different parameters, use a generic.
Associated types: one implementation per type
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Iterator uses an associated type because a
Vec<i32> iterator always yields i32. It
doesn't make sense for the same iterator to yield i32 sometimes
and String other times. There's one natural implementation, so
there's one associated type.
The payoff at the call site is cleaner code. When you call a function that returns an iterator, you don't need turbofish:
// Associated type: no ambiguity, no turbofish
let sum: i32 = numbers.iter().sum();
// If Iterator were generic (Iterator<Item>), you'd need:
// let sum: i32 = numbers.iter::<i32>().sum::<i32>(); // awful
Generic parameters: multiple implementations per type
trait From<T> {
fn from(value: T) -> Self;
}
From is generic because a single type can be converted from many
sources. String implements From<&str>,
From<Vec<u8>>,
From<char>, and more. Each is a different implementation
of the same trait, parameterized by the source type.
Another way to think about it: associated types are "output" types determined
by the implementor. Generic parameters are "input" types chosen by the caller
or the use site. Iterator decides what it yields. But
From is called with whatever source type the caller has.
When you need both
Sometimes a trait has both. Add<Rhs> has a generic
parameter for the right-hand side and an associated type for the output:
trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
A Matrix might implement Add<Matrix> (matrix
plus matrix) and Add<f64> (matrix plus scalar). The
Rhs is generic because there are multiple valid right-hand
sides. But for each specific Rhs, there's exactly one sensible
output type, so Output is associated.
Extension Traits
Rust's orphan rule says you can't implement a foreign trait for a foreign
type. You can't add methods to Iterator directly -- it's defined
in std, and you don't own it. But you can define a new
trait that extends Iterator and provides a blanket
implementation for all iterators.
trait IteratorExt: Iterator {
fn collect_vec(self) -> Vec<Self::Item>
where
Self: Sized,
{
self.collect()
}
fn try_find<E>(
&mut self,
mut f: impl FnMut(&Self::Item) -> Result<bool, E>,
) -> Result<Option<Self::Item>, E>
where
Self: Sized,
{
for item in self {
if f(&item)? {
return Ok(Some(item));
}
}
Ok(None)
}
}
// Blanket implementation: every Iterator automatically gets these methods
impl<I: Iterator> IteratorExt for I {}
Now any iterator in your codebase has .collect_vec() and
.try_find(). The itertools crate is built
entirely on this pattern -- it defines Itertools as an
extension trait with a blanket impl for all iterators, adding dozens of
useful methods.
This pattern isn't just for std types. It's useful inside your
own projects too. Say you have a domain-specific operation that applies to
anything that implements Read:
use std::io::Read;
trait ReadExt: Read {
fn read_exact_or_eof(&mut self, buf: &mut [u8]) -> Result<usize, io::Error> {
let mut total = 0;
while total < buf.len() {
match self.read(&mut buf[total..])? {
0 => break,
n => total += n,
}
}
Ok(total)
}
}
impl<R: Read> ReadExt for R {}
The convention is to name the extension trait FooExt where
Foo is the trait being extended. Users import the extension
trait and the methods appear on all types that implement the base trait.
Clean, discoverable, and it doesn't require modifying any existing code.
Supertraits
When you write trait Widget: Display + Debug, you're saying
that anything implementing Widget must also implement
Display and Debug. The syntax looks like
inheritance, but the mental model is different: supertraits are
requirements, not a class hierarchy.
use std::fmt;
trait Widget: fmt::Display + fmt::Debug {
fn render(&self, area: Rect);
fn handle_event(&mut self, event: Event) -> bool;
}
You're not saying a Widget "is a" Display. You're
saying "implementing Widget requires Display as a
prerequisite." This means inside any code that has a dyn Widget
or a T: Widget, you can call Display and
Debug methods -- they're guaranteed to exist.
fn log_and_render(widget: &dyn Widget, area: Rect) {
println!("Rendering: {}", widget); // Display is guaranteed
println!("Debug: {:?}", widget); // Debug is guaranteed
widget.render(area);
}
Supertraits are especially useful for dyn Trait objects. Without
the supertrait bound, you'd need dyn Widget + Display + Debug,
which quickly gets unwieldy and may not even be object-safe. With the
supertrait, dyn Widget alone gives you everything.
A common pattern in libraries: require Send + Sync as
supertraits when your trait will be used across threads.
// Any Handler must be safe to share across threads
trait Handler: Send + Sync {
fn handle(&self, request: &Request) -> Response;
}
// Now Arc<dyn Handler> works without extra bounds everywhere
struct Server {
handlers: Vec<Arc<dyn Handler>>,
}
Without the supertrait, every function that stores or shares handlers would
need to repeat dyn Handler + Send + Sync. The supertrait makes
the requirement part of the trait's contract.
Sealed Traits
Sometimes you want a trait that external code can use but not implement. This matters for library design: if you control all the implementations, you can add new methods to the trait without it being a breaking change. If external code can implement the trait, adding a method breaks every external implementation.
The sealed trait pattern prevents external implementations:
mod private {
pub trait Sealed {}
}
pub trait DatabaseDriver: private::Sealed {
fn connect(&self, url: &str) -> Result<Connection, Error>;
fn execute(&self, query: &str) -> Result<Rows, Error>;
}
// Only types we impl Sealed for can implement DatabaseDriver
impl private::Sealed for Postgres {}
impl DatabaseDriver for Postgres { /* ... */ }
impl private::Sealed for Sqlite {}
impl DatabaseDriver for Sqlite { /* ... */ }
The trick: Sealed is in a mod private -- it's
pub inside the module (so it can be used as a supertrait), but
the module itself isn't public to external crates. External code can see
DatabaseDriver and call its methods, but can't implement
Sealed, so can't implement DatabaseDriver.
When to seal a trait: when your trait is an API contract and you want the
freedom to evolve it. Adding a new required method to an unsealed trait is a
breaking change. Adding one to a sealed trait is fine -- you control all the
implementations. Many std traits are effectively sealed for this
reason.
There's a lighter-weight alternative if you just want to allow new methods without breaking: provide a default implementation. But default implementations can only use other methods on the trait -- they can't access internal state. Sealing gives you full control.
Putting It Together
Let's design a small plugin system that uses several of these patterns. We're building a data pipeline where plugins can transform records. The requirements: plugins must be thread-safe, some pipeline stages are known at compile time, and users can add custom plugins at runtime.
use std::fmt;
use std::sync::Arc;
// Supertrait: every Plugin must be Send + Sync + Debug
pub trait Plugin: Send + Sync + fmt::Debug {
/// Human-readable name for logging
fn name(&self) -> &str;
/// Transform a record, returning None to drop it
fn process(&self, record: Record) -> Option<Record>;
}
// Extension trait: add convenience methods to any Plugin
pub trait PluginExt: Plugin {
fn chain(self, next: impl Plugin + 'static) -> ChainedPlugin<Self, impl Plugin>
where
Self: Sized,
{
ChainedPlugin { first: self, second: next }
}
}
impl<P: Plugin> PluginExt for P {}
// ChainedPlugin composes two plugins -- uses generics for zero-cost
#[derive(Debug)]
pub struct ChainedPlugin<A, B> {
first: A,
second: B,
}
impl<A: Plugin, B: Plugin> Plugin for ChainedPlugin<A, B> {
fn name(&self) -> &str { "chained" }
fn process(&self, record: Record) -> Option<Record> {
self.first.process(record)
.and_then(|r| self.second.process(r))
}
}
// The pipeline uses dyn Trait for runtime flexibility
pub struct Pipeline {
plugins: Vec<Arc<dyn Plugin>>,
}
impl Pipeline {
pub fn new() -> Self {
Pipeline { plugins: Vec::new() }
}
// impl Plugin: accept any concrete plugin type
pub fn add(&mut self, plugin: impl Plugin + 'static) {
self.plugins.push(Arc::new(plugin));
}
pub fn run(&self, mut record: Record) -> Option<Record> {
for plugin in &self.plugins {
println!("Running plugin: {}", plugin.name());
record = plugin.process(record)?;
}
Some(record)
}
}
Count the patterns at work here:
Supertraits (Send + Sync + Debug) guarantee
every plugin is thread-safe and debuggable. The Pipeline can
use Arc<dyn Plugin> without extra bounds.
Extension traits (PluginExt) add a
.chain() method to every Plugin via a blanket
impl. Users can compose plugins without the pipeline knowing about
composition.
Generics (ChainedPlugin<A, B>) give
zero-cost composition. When two concrete plugins are chained, the compiler
monomorphizes the process call -- no vtable overhead in the inner loop.
dyn Trait (Vec<Arc<dyn Plugin>>)
enables runtime flexibility. Users can add any plugin at runtime. The
pipeline doesn't need to know about every plugin type at compile time.
impl Trait (add(&mut self, plugin: impl Plugin))
makes the add method ergonomic -- callers pass concrete types
without boxing them manually.
If this were a library and we wanted to prevent external implementations
(maybe we need to evolve the trait), we'd seal it. If the plugin's
process method needed to return a specific associated type
rather than just Option<Record>, we'd use an associated
type. The decision depends on the requirements, and now you have the
vocabulary to make it.
That's the thing about trait design in Rust. The individual patterns are simple. The skill is knowing which combination of patterns serves your specific problem -- and resisting the urge to reach for the most complex option when a simpler one would do.
Telex