S13: Trait hierarchy that copies a Java class hierarchy
What it looks like
Section titled “What it looks like”Four traits, each one “extending” the one before, as a Java Invoice extends PayableDocument extends Document extends AuditedEntity extends BaseEntity hierarchy would:
pub trait Entity { fn id(&self) -> u64;}
pub trait AuditedEntity: Entity { fn created_by(&self) -> &str;}
pub trait Document: AuditedEntity { fn number(&self) -> &str;}
pub trait PayableDocument: Document { fn amount_cents(&self) -> u64; fn is_paid(&self) -> bool;}
pub struct Invoice { pub id: u64, pub created_by: String, pub number: String, pub amount_cents: u64, pub paid: bool,}
impl Entity for Invoice { fn id(&self) -> u64 { self.id }}impl AuditedEntity for Invoice { fn created_by(&self) -> &str { &self.created_by }}impl Document for Invoice { fn number(&self) -> &str { &self.number }}impl PayableDocument for Invoice { fn amount_cents(&self) -> u64 { self.amount_cents } fn is_paid(&self) -> bool { self.paid }}
pub fn open_amount(docs: &[&dyn PayableDocument]) -> u64 { docs.iter() .filter(|d| !d.is_paid()) .map(|d| d.amount_cents()) .sum()}Why AI agents produce it
Section titled “Why AI agents produce it”- It translates the Java design. A Java domain model often has a
BaseEntitywith anid, then an audited subclass, then more levels. The agent maps each class to a trait, andextendsto a supertrait (trait Document: AuditedEntity). - Supertraits look like inheritance.
trait A: Breads likeinterface A extends B, and it is the closest syntax that Rust has. - It feels like good design. Layers of abstraction look planned and reusable.
Why it is a problem
Section titled “Why it is a problem”- There is only one type. Four traits, and only
Invoiceimplements them. Each trait is an abstraction with no second user. - Each type needs four
implblocks. A second document type needs all four again. Traits cannot hold fields, so eachimplonly forwards to a field. In this example, fourimplblocks only say “Invoicehas these fields”. - The functions use
dyn.open_amount(docs: &[&dyn PayableDocument])makes a dynamic call for each field read, for a list that only ever holds invoices. - It gives nothing that inheritance gives in Java. There are no shared fields and no shared method bodies. Rust has no subclass, so the hierarchy is names only.
The better version
Section titled “The better version”WorseFour supertraits, four
implblocks that forward to fields, and a&dynfunction.pub trait Entity {fn id(&self) -> u64;}pub trait AuditedEntity: Entity {fn created_by(&self) -> &str;}pub trait Document: AuditedEntity {fn number(&self) -> &str;}pub trait PayableDocument: Document {fn amount_cents(&self) -> u64;fn is_paid(&self) -> bool;}pub struct Invoice {pub id: u64,pub created_by: String,pub number: String,pub amount_cents: u64,pub paid: bool,}impl Entity for Invoice {fn id(&self) -> u64 {self.id}}impl AuditedEntity for Invoice {fn created_by(&self) -> &str {&self.created_by}}impl Document for Invoice {fn number(&self) -> &str {&self.number}}impl PayableDocument for Invoice {fn amount_cents(&self) -> u64 {self.amount_cents}fn is_paid(&self) -> bool {self.paid}}pub fn open_amount(docs: &[&dyn PayableDocument]) -> u64 {docs.iter().filter(|d| !d.is_paid()).map(|d| d.amount_cents()).sum()}BetterA struct with an
Auditfield for the shared data, and a function that takes&[Invoice].pub struct Audit {pub created_by: String,}pub struct Invoice {pub id: u64,pub audit: Audit,pub number: String,pub amount_cents: u64,pub paid: bool,}pub fn open_amount(invoices: &[Invoice]) -> u64 {invoices.iter().filter(|i| !i.paid).map(|i| i.amount_cents).sum()}
When a second type really needs the same behavior, add one small trait for that behavior:
/// Add a trait when two or more types really need the same behavior.pub trait Payable { fn open_cents(&self) -> u64;}
impl Payable for Invoice { fn open_cents(&self) -> u64 { if self.paid { 0 } else { self.amount_cents } }}
pub struct Subscription { pub monthly_cents: u64, pub months_due: u64,}
impl Payable for Subscription { fn open_cents(&self) -> u64 { self.monthly_cents * self.months_due }}
pub fn total_open(items: &[&dyn Payable]) -> u64 { items.iter().map(|i| i.open_cents()).sum()}Payable has one method, and two types implement it in different ways. That is a trait with a reason to exist.
When it is acceptable
Section titled “When it is acceptable”- Supertraits are correct when a trait needs another trait’s methods in its default methods, or when every implementer must also have that capability:
trait Shape: Debug, ortrait Error: Debug + Displayin the standard library. - Libraries with many implementers sometimes have two or three levels, for example
ReadandBufReadinstd::io. Each level adds real methods, and many types implement each one.
How to detect it
Section titled “How to detect it”- No Clippy lint reports this pattern.
- Search for
trait \w+:with a trait name after the colon. Two or more levels on domain traits is a sign. - For each trait, count its implementations. A trait with one
impl, outside a library, needs a reason. - Look for
implblocks that only returnself.field. Those are fields in disguise.
What to tell your agent
Section titled “What to tell your agent”Flatten the trait hierarchy. Remove the traits that only one typeimplements, and use the struct and its fields directly. Put shared fieldsin their own struct (for example Audit) and use it as a field. Keep or adda trait only for behavior that two or more types implement differently,with as few methods as possible. Replace &dyn parameters with concretetypes where only one type is passed. Run the tests.Related: S14: Deref used to imitate inheritance, S22: Box<dyn Trait> where a generic or enum is sufficient, and Traits vs interfaces.