S22: Box<dyn Trait> where a generic or enum is sufficient
What it looks like
Section titled “What it looks like”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)}Why AI agents produce it
Section titled “Why AI agents produce it”- It is the Java strategy pattern. An
Exporterinterface, 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.”
Why it is a problem
Section titled “Why it is a problem”- The set of types is known. There are two formats, and they are in the same module. A trait, two structs, and two
implblocks 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 asdyn. Code that starts withdynoften hits these limits later and works around them. Box<dyn Trait>as a parameter takes ownership for nothing.write_reportonly calls a method.&dyn Exporter, or a generic, borrows instead.
The better version
Section titled “The better version”WorseA trait, two unit structs, a string factory that returns
Box<dyn Exporter>, and aBoxparameter.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)}BetterA
Formatenum with anexportmethod. ACopyvalue, 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 it is acceptable
Section titled “When it is acceptable”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.
How to detect it
Section titled “How to detect it”- No Clippy lint reports this pattern. Clippy
needless_pass_by_value(pedantic) reports theBox<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
&stror abooland returnBox<dyn ...>. The parameter is often an enum in disguise.
What to tell your agent
Section titled “What to tell your agent”The Exporter trait has two implementations, both in this module, so replaceit with enum Format { Csv, Json } and a method export(self, rows). Replacethe string factory with the enum, or with a FromStr impl that returns anerror for unknown formats. Where a function takes Box<dyn Trait> only tocall methods, take a generic parameter or &impl Trait instead. KeepBox<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.