← Rust: Zero to Depth
Full-stack DevRust: Zero to Depth

Traits: Contracts for Shared Behavior, Not Family Trees

Part 5 of the Rust: Zero to Depth series: what a trait is, why the orphan rule protects you, how a world without inheritance organizes code through composition, what one line of derive buys you, and the six standard traits you'll deal with every day — Debug, Display, From, Clone, Drop, and Iterator.

RustTraitsCompositionStandard LibraryIdioms

Everyone arriving from an object-oriented language has an inheritance tree on their hard drive they’d love to burn. Person begets Employee, Employee begets Manager — and then one day you add a field to the base class and watch it ripple through forty subclass constructors; or you need something that “serializes and logs” and start agonizing over whose child it should be. Inheritance welds “what something is” to “what something can do” onto a single-rooted tree, and real-world capabilities have never been distributed along a tree.

Part 4 answered “what data is”; this part answers “what data can do.” Rust’s answer is the trait: a contract of behavior that any type can sign, as many times as it likes, independently — no parents, no diamond problem, no base class you can accidentally break. From this part on, your code starts to look like Rust — and eighty percent of that is traits.

A trait is a contract, not a tree

The syntax is plain to the point of being boring:

trait Summary {
    fn summarize(&self) -> String;           // required
    fn preview(&self) -> String {            // default, overridable
        format!("{}…", &self.summarize()[..50])
    }
}

impl Summary for Report {
    fn summarize(&self) -> String {
        format!("{} ({} words)", self.title, self.word_count)
    }
}

The type Report signs the contract Summary: it provides summarize and gets the default preview for free. Notice the signing happens in an impl block, not in the type definition — behavior is attached afterward, not inherited at birth. That single distinction produces the trait’s most important property: you can implement your traits for other people’s types. Implement Summary for the standard library’s Vec<T>? Legal. Implement someone else’s trait for your Report? Also legal.

The one thing you cannot do is implement someone else’s trait for someone else’s type — that’s the orphan rule: in any impl, either the trait or the type must be local to your crate. It looks like a restriction; it’s actually a guard rail. Without it, two dependencies could each implement the same trait for Vec<String>, and your build result would depend on link order — not a bug anyone wants to meet in production. The idiomatic escape is the newtype pattern: wrap it in struct MyVec(Vec<String>), the type is now yours, and the rule is satisfied.

A world without inheritance organizes by composition

The real disease of the inheritance tree isn’t depth — it’s that it forces every type to choose exactly one parent, so “what it can do” gets disguised as “what it is.” Traits tear the disguise off: a capability is a capability, and a type signs as many as it wants.

Compare the two mental models:

  • Inheritance asks: “Whose child am I?” — one answer only; change the top, shake the bottom; the diamond problem has haunted language designers for decades.
  • Traits ask: “Which contracts have I signed?” — a list, each contract oblivious to the others; add a capability by adding an impl block.

Generic functions consume that list just as directly. fn show(x: &impl Summary) reads as “anything that signed Summary” — the function doesn’t care what the value is, only what it can do. That’s why Rust has no inheritance and never misses it: inheritance tried to solve “reuse implementation” and “express interfaces” with one tree and did both awkwardly; traits handle only interfaces, and reuse goes to composition — shared logic lives in plain fields and plain functions held by the types that need them. Two problems, each solved cleanly.

And then there’s the free lunch sold by one line — #[derive(...)]:

#[derive(Debug, Clone, PartialEq)]
struct Report { title: String, word_count: usize }

The compiler generates the implementation mechanically from your fields. Debug, Clone, PartialEq, Eq, Hash, Default — the traits that involve “no surprises, only manual labor” are standard practice to derive; save the handwriting for the ones that need judgment.

The six standard traits you’ll touch every day

The most-used traits in the standard library fit on one hand, and together they form the ground-level grammar of “idiomatic Rust”:

  • Debug vs Display — two faces of “turn into a string.” Debug is for programmers ({:?}, in logs and assertions) and should be derived, always; Display is for users ({}) and must be written by hand — what counts as “nice to look at” is a judgment call no machine can make for you. Implement Display and ToString arrives automatically — that’s the compound interest of the trait system: sign one contract, unlock a chain of associated capabilities.
  • From / Into — conversions with a name. This is what ? used in Part 4 to convert errors. The convention: implement From, get Into for free — sign String: From<&str> and "hi".into() works. From promises the conversion cannot fail; fallible conversions go through TryFrom.
  • Clone — explicit copies. In Rust, copying is always written out: no implicit copy constructor quietly heap-allocating in the dark. Clone makes .clone() an honest statement of “I’m paying for a second ownership here.” (Copy is the special case: small types where a bitwise copy suffices, like numbers — copies happen implicitly because they cost nothing.)
  • Drop — deterministic cleanup. Runs automatically when a value leaves scope: files close, locks release, connections return to the pool. This is RAII as Rust knows it, and the extension point behind Part 2’s sentence “when the owner goes away, the value is cleaned up.” You’ll rarely write it by hand, but understanding it explains why Rust’s resource management needs no finally.
  • Iterator — one method unlocks an entire pipeline. The most profitable contract of the six, and worth its own section.

Iterator: sign next(), get the whole ecosystem

The entire obligation of the Iterator trait is a single method: fn next(&mut self) -> Option<Self::Item> — either hand over the next element or say None. And the standard library has built seventy-some adapters on top of it. This is the best exhibit of trait design power: a tiny contract, enormous compound interest.

let squares: Vec<u32> = (1..=100)
    .filter(|n| n % 2 == 0)
    .map(|n| n * n)
    .take(3)
    .collect();          // [4, 16, 36]

Two properties make it more than syntactic sugar:

  1. Laziness. Adapters like filter, map, and take do nothing — no allocation, no iteration; they just assemble a plan. Only when a consumer like collect (or sum, or a for loop) starts pulling do values flow through the pipeline, one at a time. No intermediate collections, no wasted passes.
  2. Zero cost. The whole chain compiles down to the same assembly as the hand-written loop you’d have written — the most literal fulfillment of the “zero-cost abstraction” promise. Write more declarative code, pay nothing at runtime.

And the contract is symmetric: implement next() for your own type and it instantly plugs into all seventy adapters — map, filter, zip, fold, collect, all free. In the inheritance world, that would mean extending some giant AbstractCollection; in the trait world, you signed one line of obligation.

Practice, then Part 6

  1. Implement Display for Part 4’s Connection: print the session id for Connected, the reason for Failed. Then derive Debug and print the same value with {} and {:?} — feel what the two faces, “for users” and “for you,” should each look like.
  2. Write trait Area { fn area(&self) -> f64; }, implement it for Circle and Rect, then write fn total_area(shapes: &[impl Area]) -> f64. Now swap impl Area for the full generic form <T: Area> that Part 7 will cover in depth — confirm both compile to the same behavior and file that equivalence into muscle memory.
  3. Without a for loop, using only iterator adapters: given a vector of log lines, sum the byte length of all lines containing "ERROR". Then try “group the lines in threes” — hint: chunks is not an Iterator adapter but a slice method; find it in the docs. Today is the day you learn where the answers live.

Part 6 returns to ownership, this time carrying escape hatches: Box, Rc, RefCell — how smart pointers move borrow checking from compile time to runtime, when that trade is worth making, and where the “single ownership” rule’s real elastic boundary lies. After it, you’ll know that behind every “no” the borrow checker says, there’s a clearly priced door.

guest@swangnice:~$