Skip to content

S13: Trait hierarchy that copies a Java class hierarchy

S13Severity: Medium

Deep supertrait chains that imitate inheritance.

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

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()
}
  • It translates the Java design. A Java domain model often has a BaseEntity with an id, then an audited subclass, then more levels. The agent maps each class to a trait, and extends to a supertrait (trait Document: AuditedEntity).
  • Supertraits look like inheritance. trait A: B reads like interface A extends B, and it is the closest syntax that Rust has.
  • It feels like good design. Layers of abstraction look planned and reusable.
  • There is only one type. Four traits, and only Invoice implements them. Each trait is an abstraction with no second user.
  • Each type needs four impl blocks. A second document type needs all four again. Traits cannot hold fields, so each impl only forwards to a field. In this example, four impl blocks only say “Invoice has 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.
  1. WorseFour supertraits, four impl blocks that forward to fields, and a &dyn function.

    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()
    }
  2. BetterA struct with an Audit field 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.

  • 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, or trait Error: Debug + Display in the standard library.
  • Libraries with many implementers sometimes have two or three levels, for example Read and BufRead in std::io. Each level adds real methods, and many types implement each one.
  • 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 impl blocks that only return self.field. Those are fields in disguise.
Flatten the trait hierarchy. Remove the traits that only one type
implements, and use the struct and its fields directly. Put shared fields
in their own struct (for example Audit) and use it as a field. Keep or add
a trait only for behavior that two or more types implement differently,
with as few methods as possible. Replace &dyn parameters with concrete
types 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.