Skip to content

S22: Box<dyn Trait> where a generic or enum is sufficient

S22Severity: Low

Dynamic dispatch and a heap allocation where the set of types is known.

Clippy: no lint finds this. Look for it in review.

Two export formats, a trait, a factory that returns Box<dyn Exporter>, and a function that takes the box:

pub trait Exporter {
fn export(&self, rows: &[(u64, u64)]) -> String;
}
pub struct Csv;
pub struct Json;
impl Exporter for Csv {
fn export(&self, rows: &[(u64, u64)]) -> String {
rows.iter()
.map(|(id, cents)| format!("{id},{cents}"))
.collect::<Vec<_>>()
.join("\n")
}
}
impl Exporter for Json {
fn export(&self, rows: &[(u64, u64)]) -> String {
let items: Vec<String> = rows
.iter()
.map(|(id, cents)| format!("{{\"id\":{id},\"cents\":{cents}}}"))
.collect();
format!("[{}]", items.join(","))
}
}
pub fn exporter_for(format: &str) -> Box<dyn Exporter> {
if format == "json" {
Box::new(Json)
} else {
Box::new(Csv)
}
}
pub fn write_report(exporter: Box<dyn Exporter>, rows: &[(u64, u64)]) -> String {
exporter.export(rows)
}
  • It is the Java strategy pattern. An Exporter interface, one class per format, and a factory method. Box<dyn Exporter> is the closest Rust type to an interface reference.
  • It always compiles. Generic code can fail with errors about bounds and sizes. Box<dyn Trait> avoids most of them, so it is the agent’s fallback when other types do not compile.
  • It looks extensible. “Someone can add a format later.”
  • The set of types is known. There are two formats, and they are in the same module. A trait, two structs, and two impl blocks describe what one enum describes.
  • A typo is a valid format. exporter_for("jsn") returns CSV, silently, because the factory takes a &str.
  • It costs a heap allocation and a dynamic call for each exporter. The cost is small here, but in a loop over many values it is real, and the compiler cannot inline through a vtable.
  • Trait objects have limits. A trait with generic methods, or methods that return Self, cannot be used as dyn. Code that starts with dyn often hits these limits later and works around them.
  • Box<dyn Trait> as a parameter takes ownership for nothing. write_report only calls a method. &dyn Exporter, or a generic, borrows instead.
  1. WorseA trait, two unit structs, a string factory that returns Box<dyn Exporter>, and a Box parameter.

    pub trait Exporter {
    fn export(&self, rows: &[(u64, u64)]) -> String;
    }
    pub struct Csv;
    pub struct Json;
    impl Exporter for Csv {
    fn export(&self, rows: &[(u64, u64)]) -> String {
    rows.iter()
    .map(|(id, cents)| format!("{id},{cents}"))
    .collect::<Vec<_>>()
    .join("\n")
    }
    }
    impl Exporter for Json {
    fn export(&self, rows: &[(u64, u64)]) -> String {
    let items: Vec<String> = rows
    .iter()
    .map(|(id, cents)| format!("{{\"id\":{id},\"cents\":{cents}}}"))
    .collect();
    format!("[{}]", items.join(","))
    }
    }
    pub fn exporter_for(format: &str) -> Box<dyn Exporter> {
    if format == "json" {
    Box::new(Json)
    } else {
    Box::new(Csv)
    }
    }
    pub fn write_report(exporter: Box<dyn Exporter>, rows: &[(u64, u64)]) -> String {
    exporter.export(rows)
    }
  2. BetterA Format enum with an export method. A Copy value, no heap, and a typo does not compile.

    /// The set of formats is known and small: an enum.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub enum Format {
    Csv,
    Json,
    }
    impl Format {
    pub fn export(self, rows: &[(u64, u64)]) -> String {
    match self {
    Self::Csv => rows
    .iter()
    .map(|(id, cents)| format!("{id},{cents}"))
    .collect::<Vec<_>>()
    .join("\n"),
    Self::Json => {
    let items: Vec<String> = rows
    .iter()
    .map(|(id, cents)| format!("{{\"id\":{id},\"cents\":{cents}}}"))
    .collect();
    format!("[{}]", items.join(","))
    }
    }
    }
    }

When the caller chooses one type per call, such as a pricing rule, a generic is the right tool. Each call is compiled for its type, with no box:

/// One caller-chosen type per call: a generic, no box, no vtable.
pub fn total_by<F: Fn(u64) -> u64>(rows: &[(u64, u64)], price_rule: F) -> u64 {
rows.iter().map(|&(_, cents)| price_rule(cents)).sum()
}

When the set of types is open and the values are mixed in one collection. Here, other code adds its own checks at run time:

/// An open set: other crates add their own checks at run time, and the list
/// holds different types. This is what trait objects are for.
pub trait Check {
fn name(&self) -> String;
fn passes(&self, cents: u64) -> bool;
}
#[derive(Default)]
pub struct Validator {
checks: Vec<Box<dyn Check>>,
}
impl Validator {
pub fn add(&mut self, check: Box<dyn Check>) {
self.checks.push(check);
}
pub fn failures(&self, cents: u64) -> Vec<String> {
self.checks
.iter()
.filter(|c| !c.passes(cents))
.map(|c| c.name())
.collect()
}
}
  • Plugins and handlers registered at run time: middleware, event listeners, validation rules.
  • Errors: Box<dyn std::error::Error> in applications and tests is a common, correct use. See Result, not exceptions.
  • To reduce compile time or binary size in a large generic API, by design. This is a decision, and it is usually documented.
  • No Clippy lint reports this pattern. Clippy needless_pass_by_value (pedantic) reports the Box<dyn Exporter> parameter that is only borrowed, which is part of the smell.
  • Search for Box<dyn. For each trait, count the implementations, and check if they are all in this crate.
  • Look for factory functions that take a &str or a bool and return Box<dyn ...>. The parameter is often an enum in disguise.
The Exporter trait has two implementations, both in this module, so replace
it with enum Format { Csv, Json } and a method export(self, rows). Replace
the string factory with the enum, or with a FromStr impl that returns an
error for unknown formats. Where a function takes Box<dyn Trait> only to
call methods, take a generic parameter or &impl Trait instead. Keep
Box<dyn ...> only for open sets of types in one collection. Run the tests.

Related: S13: Trait hierarchy that copies a Java class hierarchy, Traits vs interfaces, and Generics.