Learning Rust · Part II · Chapter 03

Traits, not inheritance

In Go, Java, or Python you reach for a base class to share behavior and inherit a whole tree of coupling you did not want; Rust shares behavior by letting a type opt into a named contract, and lets you choose whether the abstraction costs anything at runtime.

01

The base class you did not want

If you have spent years in an object-oriented language, you have written this shape a hundred times. A base class holds the fields and methods every subclass shares, and each subclass extends it to add or override a little. Animal has a name and a describe method; Dog and Robot inherit them and tweak what they need. It feels tidy, and for a while it is. The trouble is what you signed up for without noticing: a single extends bundled two completely different wishes into one mechanism, and tied them both to a class tree you now have to live inside.

The two wishes are worth separating, because they have nothing to do with each other. The first is code reuse: you wrote describe once and you do not want to write it again in every subclass. The second is polymorphism: you want to hold a Dog and a Robot in the same list and call the same method on each without caring which is which. Inheritance answers both at once, but only by making you accept a third thing you never asked for: a hierarchy. To get the reuse and the polymorphism, you must declare that a Dog is an Animal, slot it into the tree, and inherit everything the base class happens to expose, whether it fits or not.

That coupling is where the pain lives, and it has a name OO programmers learn the hard way: the fragile base class. Because every subclass depends on the exact behavior of a parent it did not write, a change to the parent ripples down silently. Rework describe in Animal to call a new helper, and a Robot three levels down that overrode part of the chain can break, with nothing at the change site to warn you. The base class is a contract nobody signed and everybody depends on. Go programmers feel this from the other side: Go deliberately left struct inheritance out, reached for interfaces and embedding instead, and the language is plainly fine without an extends keyword. That is the hint Rust runs with.

Rust splits the bundle back into its parts and hands you each one separately. The reuse wish is answered by a trait with default methods: shared behavior written once, that a type opts into without inheriting any fields or climbing any tree. The polymorphism wish is answered by traits combined with generics or with dyn, which is the rest of this chapter. No base class is ever involved, and no type is ever forced to be a kind of another type just to share a method. A Dog and a Button can share the ability to be drawn without pretending to be siblings.

So the altitude for this chapter is the feeling of sharing behavior by composition rather than by hierarchy. You will see one trait implemented for two unrelated types, used through that trait, and you will see that the abstraction costs nothing at runtime. This is not yet the chapter where you satisfy the borrow checker or annotate anything; it is the chapter where the class tree quietly disappears and you stop missing it. The picture below is the whole argument in one frame: a forced vertical hierarchy on the left, a free horizontal opt-in on the right.

Figure · the tree you inherit vs the grid you opt into
INHERITANCE · ONE TREETRAITS · OPT-IN GRIDAnimalDogRobotcoupled: change Animal, break bothtrait Greettrait DrawDogimplButtonimplRobotimplimplcapability is a grid
Inheritance forces one vertical hierarchy, so every subclass is coupled to a base it did not write. Traits let each type opt into the behaviors it wants, so capability is a grid, not a tree.
Two wishes, one bad bargain

Inheritance answers two separate wishes, code reuse and polymorphism, by forcing you to take both at once plus a class hierarchy you did not ask for. Rust splits them apart: default trait methods give you the reuse, traits combined with generics or dyn give you the polymorphism, and no base class is ever involved. You get to want one without paying for the other two.

Rust has no struct inheritance at all

A Rust struct cannot extend another struct to inherit its fields or its methods. There is no extends and no base class anywhere in the language, and no super.method() call up a parent chain. If you came looking for one, the honest answer is that you will not need it, and the rest of this chapter is the reason why. Shared behavior arrives by implementing a trait, not by climbing a tree.

02

A trait is a contract a type opts into

Here is the word and its definition in one breath. A trait is a named set of method signatures that a type can choose to implement. That is the whole idea. You write down a contract once ("anything that is a Summary can produce a headline"), and then individual types sign up for it. If you have written Go, you have met this already under a different name: a trait is very close to a Go interface, a named set of methods a type promises to provide. The same instinct, the same shape.

Two differences are worth stating honestly, because they are exactly where Rust and Go part ways. The first is about how a type joins the contract. In Go, conformance is structural and implicit: any type that happens to have the right methods satisfies the interface, with nothing written down at the type to say so. In Rust you make it explicit. You write impl Summary for Article, and that line is you declaring, out loud, that Article fulfils the contract. The compiler then checks the method set at compile time, so a mismatch is an error you see immediately, not a surprise at the call site later.

The second difference is the headline of this whole chapter, and we only name it here. In Go, a call through an interface is resolved at runtime, through a lookup table. A Rust trait lets you choose: the same trait can be dispatched statically (the compiler bakes in the exact method, with no lookup and no cost) or dynamically (a runtime lookup, like Go's). Same contract, two ways to spend it. Sections 04 and 05 cash that choice out; for now just hold the fact that the choice exists, because that single fact is most of what makes traits feel different from interfaces.

The smallest possible version of all this is one trait, one method, one type that opts in. Read the snippet for the three moves: trait Summary declares the contract, struct Article is a plain data type that knows nothing about it yet, and impl Summary for Article is the moment the type signs up and supplies the one required method. After that line, article.headline() is just a method call, and the program prints the headline it builds.

a_contract.rs
// A trait is a named contract: a set of method signatures a type opts into.
// Here the contract has exactly one method.
trait Summary {
    fn headline(&self) -> String;
}

// One concrete type that will opt into the contract.
struct Article {
    title: String,
    words: u32,
}

// "impl Summary for Article" is you saying, out loud, that Article fulfils
// the contract. The compiler checks that the method set matches exactly.
impl Summary for Article {
    fn headline(&self) -> String {
        format!("{} ({} words)", self.title, self.words)
    }
}

fn main() {
    let article = Article {
        title: String::from("Rust in prod"),
        words: 1200,
    };
    // Because Article implements Summary, it has a headline() method to call.
    println!("{}", article.headline());
}

One aside, mentioned once and then set down. Rust will only let you write impl Summary for Article if either the trait or the type is defined in your own code. This is the orphan rule, and it exists so that two unrelated libraries can never quietly disagree about how some shared type implements some shared trait. You will rarely bump into it early on; note that it is there and move on. The next section does the part that pays off: implement one trait for two different types, and use both of them through the single contract they share.

Like a Go interface, said out loud

A trait is a named set of methods a type promises to provide, the same idea as a Go interface. The difference is that Rust makes you write impl Summary for Article explicitly, so the type itself records that it joined the contract. The compiler checks the method set at compile time, and you get to choose later whether calls go through the trait statically (no runtime lookup) or dynamically (a runtime lookup, the way every Go interface call already works).

03

One trait, two types, used through it

A contract earns its keep the moment a second type signs it. So here are two structs that have nothing to do with each other: a Tweet, with a user and a body, and an Article, with a title and a word count. They are not related. Neither one is built on top of the other, neither descends from a shared base, and in a language with class inheritance you would have to invent some artificial parent (a Postable or a Content base class) just to hand them a common method. Rust asks for no such thing. The only relationship these two types will ever have is that each one promises to do the same single thing: produce a headline. Read the program, then press Run:

two_types_one_trait.rs
// One trait, two unrelated structs, used uniformly through the trait.
// Tweet and Article share no parent; the only thing they have in common
// is that each one opted into Summary.
trait Summary {
    fn headline(&self) -> String;
}

struct Tweet {
    user: String,
    body: String,
}

struct Article {
    title: String,
    words: u32,
}

// Each type opts into the contract by writing impl Summary for <type>.
// In Go a type satisfies an interface implicitly; here it is explicit.
impl Summary for Tweet {
    fn headline(&self) -> String {
        format!("@{}: {}", self.user, self.body)
    }
}

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

// &impl Summary reads as "a reference to any type that implements Summary".
// announce neither knows nor cares whether it got a Tweet or an Article.
fn announce(item: &impl Summary) {
    println!("Breaking! {}", item.headline());
}

fn main() {
    let tweet = Tweet {
        user: "ferris".to_string(),
        body: "shipped 1.96".to_string(),
    };
    let article = Article {
        title: "Rust in prod".to_string(),
        words: 1200,
    };

    announce(&tweet);
    announce(&article);
}

Look at what each type does to join in. Below its own definition, each one carries an impl Summary for ... block, and inside that block it writes the one method the contract requires. Tweet builds its headline from the user and the body; Article builds its headline from the title and the word count. The two implementations share no code and look nothing alike, which is the point: the trait fixes the shape of the behavior (a method called headline that returns a String), and leaves each type free to fill that shape however it likes. This is the look-back to I.03 made concrete: there you saw that a type carries meaning, and here the meaning the two types share is exactly the contract they both opted into, nothing more and nothing less.

Now read the one function that uses them. announce takes a parameter of type &impl Summary, which reads in plain English as a reference to any type that implements Summary. It is not a reference to a Tweet and not a reference to an Article; it is a reference to whatever shows up, as long as that thing has signed the Summary contract. Inside the function body, announce calls item.headline() without ever asking which concrete type it received. It cannot ask, and it does not need to. The trait guarantees that a headline method exists, so the call is safe, and the call site (announce(&tweet) then announce(&article)) feeds it two different types through the very same door.

This is the same uniformity a Go interface gives you. If you wrote a Go function taking a Summary interface, you could pass it a Tweet or an Article with no fuss, and that is precisely the feeling here. The &impl Summary syntax is one of two ways Rust spells this idea. It is sugar, a shorter way of writing a form you will meet in the next section, where the same function is written with a named type parameter and a trait bound. For now, treat &impl Summary as the readable phrasing of one capability: any type that can summarize itself is welcome here. The longer form says the same thing and unlocks a little more, which is exactly the next beat of this chapter.

Shared behavior without a shared ancestor

Tweet and Article have no base class and no relationship to each other; one is not a kind of the other, and neither sits under a common parent. They are interchangeable to announce for one reason only: each one implements Summary. Behavior here is shared by opting into a contract, not by descending from a common ancestor. You add a capability to a type by signing one more contract, never by climbing into a class tree and hoping the branch above you fits.

Implicit in Go, explicit in Rust

In Go a type satisfies an interface just by having the right methods: there is no declaration, and the relationship is never written down. Rust makes you write impl Summary for Tweet out loud. That extra line is the trade-off, and it cuts both ways. You pay a little more ceremony, but the relationship becomes visible at the type itself, and the compiler tells you the instant a required method is missing or its signature is wrong, instead of letting a near-miss slip through until something far away tries to call it.

04

Generics get monomorphized: the abstraction disappears

The last section called announce through &impl Summary, which accepts any type that implements the trait. That syntax is sugar. The fuller way to write the same function names a generic type and bounds it with the trait: fn announce<T: Summary>(item: &T). Read it as: for any type T that implements Summary, take a reference to one. The body does not change, the calls do not change, and the output is identical to section 03. What changes is only that the contract is now spelled out where you can see it.

Here is the same trait and the same two types, called through that explicit bound. Run it and compare the output to the previous section:

monomorphized.rs
// The same Summary trait and two impls from the previous section, but the
// function is now spelled with an explicit trait bound: announce<T: Summary>.
// It does exactly what &impl Summary did; this is the longhand form.
trait Summary {
    fn headline(&self) -> String;
}

struct Tweet {
    user: String,
    body: String,
}

struct Article {
    title: String,
    words: u32,
}

impl Summary for Tweet {
    fn headline(&self) -> String {
        format!("@{}: {}", self.user, self.body)
    }
}

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

// Generic over any T that implements Summary. At compile time the compiler
// stamps out one specialized copy of announce per concrete type it is called
// with: one for Tweet, one for Article. At run time each call is a direct
// call into that copy, with no interface table and no dispatch.
fn announce<T: Summary>(item: &T) {
    println!("Breaking! {}", item.headline());
}

fn main() {
    let tweet = Tweet {
        user: "ferris".into(),
        body: "shipped 1.96".into(),
    };
    let article = Article {
        title: "Rust in prod".into(),
        words: 1200,
    };

    announce(&tweet);   // compiled as the Tweet copy
    announce(&article); // compiled as the Article copy
}

Nothing new prints, and that is the point. What is worth understanding is how the compiler turns that one generic function into running code, a process called monomorphization. At compile time, the compiler looks at every concrete type you actually call announce with, and stamps out a separate specialized copy of the function for each one: one announce compiled for Tweet, another compiled for Article. Your single generic source becomes two ordinary, type-specific functions in the finished binary. By the time the program runs, the generic is gone.

Because each copy is specialized to one concrete type, the call into it is a plain direct call. There is no interface table to consult, no pointer to follow to find the right headline, no runtime decision at all. The machine code is exactly what you would have written if you had hand-typed two separate announce functions yourself, one per type. That is the literal meaning of zero-cost abstraction: the generic costs you nothing at run time. You write one readable function over many types, and the abstraction evaporates during compilation, leaving behind the same fast code you would have written by hand.

Zero-cost is a precise claim, not a free lunch, so state the bill honestly. The cost does not vanish; it moves. Stamping out one copy of the function per concrete type means more work for the compiler and a slightly larger binary, since those copies are real, distinct code. If you instantiate a generic with twenty types, you get twenty copies. The trade Rust makes is to spend compile time and binary size to buy run-time speed, which for systems work is usually the trade you want, but it is a trade.

This is where a Go reader should feel the difference sharply. A Go interface value always carries an itable, and every method call through that interface does a lookup in the table at run time to find the right function. The indirection is small, but it is always there, on every call. The monomorphized Rust generic has no table and no lookup; the abstraction is already resolved before the program starts. Rust does have a table-based path too, for when you genuinely need runtime-chosen behavior, but you have to ask for it out loud with dyn. That is the next section. Until you write dyn, a generic stays static and free.

Figure · monomorphization stamps out one copy per type
GENERIC SOURCEfn announce<T: Summary>(&T)DIRECT CALL, NO TABLEannounce_for_TweetDIRECT CALL, NO TABLEannounce_for_Articlestampstampcompile timerun time: as if hand-written
One generic source, fn announce<T: Summary>, becomes one specialized function per concrete type at compile time. Each is a direct call with no table, so at run time it performs as if you had hand-written it.
Zero-cost means zero runtime cost

A generic function with a trait bound is monomorphized: the compiler writes a separate specialized copy for each concrete type you call it with, so the generic call compiles to the same machine code as a hand-written call to a type-specific function. You pay no run-time cost for the abstraction. The bill moves to compile time and a slightly larger binary, one copy per type, which is the honest trade-off. Zero-cost is about the running program, not the build.

A Go interface call is not free; this is

Every call through a Go interface goes through an itable lookup at run time to find the method. The monomorphized Rust generic has no table and no indirection: the abstraction is gone by the time the program runs, so the call is direct. Rust only reaches for a runtime table when you explicitly ask for one with dyn, which is exactly the next section. Static by default, dynamic only on request.

05

Iterator chains: a pipeline that compiles to a loop

The cleanest place to feel the zero-cost claim is an iterator chain, because you can write the high-level version and the hand-written version side by side and watch them agree. The program below does one small job: out of a list of daily temperatures, keep the warm days, square each one, and add up the squares. The first version says that as a pipeline, temps.iter().copied().filter(...).map(...).sum(), which reads almost like the English sentence describing the task. Directly beneath it is the plain for loop you would have written by hand: one accumulator, one pass, one branch, one multiply. Press Run and both print the same number, 1966.

zero_cost.rs
// Read like a sentence: keep the warm days, square each one, add them up.
// After the optimizer runs in release mode this whole chain becomes a
// single pass over the array, the same machine code as the loop below.
fn main() {
    let temps = [13, 21, 8, 30, 17, 25];

    // High-level pipeline. `.copied()` turns each &u32 into a plain u32,
    // so every closure takes a value `t` and reads like ordinary arithmetic.
    let total: u32 = temps
        .iter()
        .copied()
        .filter(|&t| t >= 20)
        .map(|t| t * t)
        .sum();
    println!("sum of squares of warm days = {total}");

    // The plain hand-written loop the chain lowers to. Same work,
    // spelled out: one accumulator, one pass, one branch, one square.
    let mut acc: u32 = 0;
    for t in temps.iter().copied() {
        if t >= 20 {
            acc += t * t;
        }
    }
    println!("sum of squares of warm days = {acc}");
}

Read the pipeline left to right and it is a sentence. .iter() walks the array, .copied() turns each &u32 into a plain u32 so the closures take an ordinary value t instead of a reference, .filter(|&t| t >= 20) drops the cool days, .map(|t| t * t) squares what is left, and .sum() folds the survivors into one total. Each of those adapters, filter, map, sum, is a generic method that takes your closure as a type parameter. That matters for what happens next: because they are generic, the compiler monomorphizes them against your specific closures, the same stamp-out-a-copy machinery from the last section. There is no general-purpose pipeline object being walked at runtime, and there is no boxed closure being called through a pointer per element.

So after the optimizer has done its work, the chain is not a sequence of stages each building a little intermediate list and handing it to the next. It is a single pass over the array. filter does not produce a smaller array for map to consume; map does not produce an array of squares for sum to add. The adapters compose into one loop body, the closures inline into it, and what remains is the accumulator-and-branch loop printed right below. That is the literal meaning of a zero-cost abstraction: you wrote the pipeline, and you paid nothing extra for it over the loop. The name comes from Bjarne Stroustrup's zero-overhead principle, which the Rust documentation quotes directly: what you do not use, you do not pay for, and what you do use, you could not hand-code any better.

Be precise about what the two printed numbers prove and what they do not. They prove the pipeline and the loop compute the same result, which is the demonstration this section can make on its own. They do not, by themselves, prove the two forms compile to identical instructions. That stronger claim, the same machine code, is something the release-mode optimizer produces; it is sourced from the Rust documentation and from looking at the generated assembly, not from the printed output. In an unoptimized debug build the two forms are genuinely different instructions, and the values still match. So hold the claim at its honest altitude: in release mode the optimizer lowers this chain to the loop, and that is a property of the optimizer, not a guarantee the language makes about every build.

If you write Go, you have felt the opposite default. A Go pipeline built from helper functions and slices usually does allocate the intermediate slices and does call through function values, so the convenient version and the fast version are different code and you choose between them. Rust lets you write the convenient version and keep the fast one, because the abstraction is resolved at compile time rather than walked at runtime. Iterators have a great deal more to them, their own trait, lazy evaluation, the full set of adapters, and that formal treatment comes later in the book; here it is enough to see one chain dissolve into one loop.

What you do use, you couldn't hand-code better

The .iter().copied().filter().map().sum() chain reads like a sentence, but it is not an interpreter walking a pipeline at runtime. After optimization it is the same single-pass loop you would have written by hand: no intermediate vectors handed from one stage to the next, and no per-step closure call. That is the literal zero-cost abstraction, named after Stroustrup's zero-overhead principle, which the Rust documentation quotes: what you do not use, you do not pay for, and what you do use, you could not hand-code any better.

Same code is an optimizer claim, not a guarantee

The compiles-down-to-the-same-loop result is what the release-mode optimizer produces, not a promise the language makes about every build. In an unoptimized debug build the two forms are not identical instructions. The printed values match regardless, so read the strong claim as the optimizer lowers this to the loop, not the language guarantees identical instructions. The two printed numbers prove the results agree; the same-machine-code claim lives at the optimizer and is sourced, not proven by the snippet.

06

dyn Trait: when you choose to pay for flexibility

Generics gave you one trait used through many types at no runtime cost, but they have a quiet constraint: a generic over T: Summary picks one concrete T at each call. That is fine until you want to keep a Tweet and an Article together in the same list. A Vec holds one type, so the obvious attempt fails. Write vec![Tweet { .. }, Article { .. }] and the compiler stops you cold:

error[E0308]: mismatched types
  --> src/main.rs:18:9
   |
18 |         Article { title: "Rust in prod".into() },
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Tweet`, found `Article`

Read the message literally: the first element fixed the element type to Tweet, so the second element, an Article, is the wrong type for that slot. The two structs both implement Summary, but implementing a shared trait does not make them the same type, and a Vec is homogeneous. There is no base class here that both could inherit from to become interchangeable. To mix them you have to set their concrete types aside and keep only the one thing they share: the promise that each can produce a headline().

That erasure is what a trait object is. You write it Box<dyn Summary>: a value sitting behind a pointer, where the pointer carries two things, a pointer to the data and a pointer to a small table of the type's trait methods, called a vtable. Declaring the list as Vec<Box<dyn Summary>> says every element is some type that implements Summary, no longer which one. Now a boxed Tweet and a boxed Article sit side by side, and the same loop calls item.headline() on each. Run it and watch one loop dispatch to two different implementations:

heterogeneous.rs
// A heterogeneous collection behind one trait (dynamic dispatch).
// A Vec holds one concrete type, so a Tweet and an Article cannot share a
// plain Vec. Box<dyn Summary> erases their types behind one trait object,
// and the right headline() is chosen at runtime through a method table.
trait Summary {
    fn headline(&self) -> String;
}

struct Tweet {
    user: String,
    body: String,
}

struct Article {
    title: String,
    words: u32,
}

impl Summary for Tweet {
    fn headline(&self) -> String {
        format!("@{}: {}", self.user, self.body)
    }
}

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

fn main() {
    // One Vec, two different concrete types, unified by the dyn Summary
    // trait object. Each Box carries a data pointer and a vtable pointer.
    let feed: Vec<Box<dyn Summary>> = vec![
        Box::new(Tweet {
            user: "ferris".into(),
            body: "shipped 1.96".into(),
        }),
        Box::new(Article {
            title: "Rust in prod".into(),
            words: 1200,
        }),
    ];

    for item in &feed {
        // headline() is looked up at runtime via the trait object's vtable.
        println!("{}", item.headline());
    }
}

When that loop calls item.headline(), it does not know at compile time whether the value is a Tweet or an Article. It finds the right method by following the vtable pointer at runtime and calling whatever headline() sits in that table. This is dynamic dispatch, and it is the opposite of the monomorphized, statically dispatched generics from the last section. You buy something real, a single collection of mixed types and behavior chosen at runtime, and you pay something real: one pointer indirection per call, and the compiler can no longer inline the method, so a few optimizations are off the table. That cost is small and you pay it only at the calls you route through dyn.

If you write Go, you have been paying this cost on every interface call already. A Go interface value is a pair of pointers, one to the data and one to a method table (Go calls it an itable), and an interface method call is a table lookup at runtime, exactly like this. The difference is the choice. Go interfaces are always dynamic; Rust lets you pick static dispatch (generics, free) or dynamic dispatch (dyn, a little cost), and makes you write dyn out loud so the cost is never hidden. You reach for dyn when you genuinely need the runtime flexibility, and you keep generics everywhere else.

Figure · static dispatch vs dynamic dispatch
STATIC · GENERICS (MONOMORPHIZED)CALL SITEitem.headline()FNTweet::headlinedirect, inlinableDYNAMIC · DYN (TRAIT OBJECT)BOX<DYN SUMMARY>fat pointerdatavtableVALUETweetVTABLEheadlineFNTweet::headlineone indirection, no inlining
Static dispatch (top) calls the concrete function directly and can inline it. Dynamic dispatch (bottom) routes every call through the trait object’s vtable, one extra hop you pay for the freedom to mix types.
dyn is the cost you opt into

When you need different concrete types in one collection, or behavior chosen at runtime, you reach for Box<dyn Summary>. A trait object is a value behind a pointer plus a table of its methods (its vtable), and each call looks the method up at runtime. That costs one indirection and blocks inlining, the small, explicit price you pay only when you ask for the flexibility. Everywhere you do not need it, generics stay free.

A trait object must live behind a pointer

You cannot write let x: dyn Summary = ...;. A trait object has no fixed size (a Tweet and an Article take different amounts of room), so the compiler rejects it with E0277, "the size for values of type dyn Summary cannot be known at compilation time." A trait object must sit behind a pointer, written &dyn Summary or Box<dyn Summary>. This is the most common first stumble with dyn, and in edition 2021 the dyn keyword is required, so never drop it.

07

Composition over inheritance, and a promise about threads

Step back and look at what the chapter has actually claimed. A trait is a contract a type opts into, very close to a Go interface, and one trait can be worn by two unrelated structs that share no parent. When you need that shared behavior, you reach for it in one of two ways, and the choice is yours to make in the code. Write a generic with a trait bound and the abstraction is monomorphized: the compiler stamps out a specialized copy per concrete type, so the trait disappears at compile time and the call costs exactly what a hand-written version would. Write Box<dyn Trait> and you get dynamic dispatch: the method is found through a small table at runtime, which costs one indirection and blocks inlining, in exchange for holding different concrete types in one collection. Go does not offer this fork. A Go interface call dispatches dynamically through an itable; Rust lets you pick static and free, or dynamic and slightly priced, and the keyword dyn makes that choice visible on the page.

The guidance is short. Reach for generics by default, because the cost is zero at runtime and you almost never need anything else. Reach for dyn when you genuinely cannot avoid it: a heterogeneous collection like Vec<Box<dyn Draw>>, or behavior chosen at runtime that no single concrete type can express. That is the whole decision, and you can see which one a piece of code made just by reading whether it says <T: Trait> or dyn Trait.

All of this replaces the thing an OO reader came in expecting: a class hierarchy. There is no base class to extend and no parent method chain to call up into. Behavior is shared by composition, a type implementing the traits it needs, one at a time, with no tree to climb and no fragile parent that can break its children from above. The reuse you would have gotten from inheriting a base method still exists, but it arrives through a different door. A trait can provide a default method built on top of the one method it requires, so a type fills in a small required part and gets the larger behavior for free. The snippet below shows exactly that: SocialPost writes only summarize_author, and the default summarize calls it to produce the full sentence.

composition.rs
// A trait can ship a default method built on top of a required one.
// Implementors fill in the small required part; the rest comes for free.
trait Summary {
    // The one method an implementor must provide.
    fn summarize_author(&self) -> String;

    // A default method. It calls summarize_author, so any type that
    // implements the trait gets this for free unless it overrides it.
    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

struct SocialPost {
    username: String,
}

// We only write summarize_author. summarize is inherited from the default,
// with no base class anywhere in sight.
impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

fn main() {
    let post = SocialPost {
        username: String::from("ferris"),
    };
    // summarize() runs the default body, which calls our summarize_author().
    println!("{}", post.summarize());
}

Read what is and is not happening here. summarize has a body inside the trait itself, so it is shared the way an inherited base method would be, but there is no base class: SocialPost is a plain struct that opted into one contract. The default method calls summarize_author, which the trait only declares and the type supplies, so the shared code reaches back down into the type-specific part. That is composition giving you reuse without a hierarchy, and it is the pattern that does the work people reach for inheritance to do.

One promise is worth planting now, to be cashed much later. Rust extends this same trait machinery to concurrency through two marker traits named Send and Sync. A marker trait has no methods; it is a label the compiler attaches automatically when a type is built entirely from parts that already carry it. Send means a value is safe to move to another thread, and Sync means a reference to it is safe to share across threads. Because these labels are checked at compile time, a large class of data-race bugs turns into compile errors before the program ever runs. That is the real mechanism behind the phrase fearless concurrency, and it is a checked promise rather than a slogan. Part VIII spends it in full; here it is only a teaser, named so you recognize it when it returns.

The formal treatment of generics and traits, the parts deliberately kept off the page here, the where-clause machinery, lifetimes in bounds, associated types, the rules for what can become a dyn object, all of that is Part III. You leave this chapter able to reason in pictures about why Rust shares behavior the way it does, and able to read the static-or-dynamic choice off a function's signature. Satisfying the borrow checker from memory is a later skill; recognizing a trait as a contract, and knowing when the abstraction costs nothing versus a little, is the one to carry forward.

Generics by default, dyn when you must

Default to generics with trait bounds: the abstraction is monomorphized away and costs nothing at runtime, so this is the right reach almost every time. Choose Box<dyn Trait> only when you genuinely need different concrete types in one collection, or behavior decided at runtime that no single type can carry, and accept the small dispatch cost knowingly. The point is not that one is better; it is that the choice is yours and it is visible in the code, right where someone reads <T: Trait> or dyn Trait.

Fearless concurrency is a checked promise, not magic

Send and Sync are marker traits with no methods that the compiler implements automatically and checks for you: Send means a value is safe to move to another thread, Sync means a reference is safe to share across threads. Many data-race bugs become compile errors because of them. That is a real mechanism, not a phrase, and Part VIII spends it. Here it is only a teaser, named so it is familiar when it returns.

The whole chapter in seven lines

  1. Rust has no class inheritance: it shares behavior through traits, a named set of methods a type opts into, very close to a Go interface but checked at compile time.
  2. You implement one trait for many unrelated types and use them all through that one contract, with no shared ancestor and no base class to break.
  3. Generics with a trait bound are monomorphized: one specialized copy per concrete type, so the abstraction disappears and costs nothing at runtime.
  4. That is zero-cost abstraction, and an iterator chain shows it: .iter().filter().map().sum() lowers to the same single-pass loop you would write by hand.
  5. When you need mixed types in one collection or behavior chosen at runtime, dyn Trait gives dynamic dispatch through a vtable, the small explicit cost you opt into.
  6. Behavior is shared by composition, implementing the traits you need, not by climbing a hierarchy; you choose static (free) or dynamic (priced) per call.
  7. Send and Sync are marker traits the compiler checks to make concurrency fearless: a teaser here, cashed in Part VIII; the formal treatment of generics and traits is Part III.
End-of-chapter assessmentCheck your understanding22 questions. Your first attempt is recorded on your report card.Take it →