TL;DR
Learn to write flexible, reusable code with Rust's trait system and generic programming
Key concepts
- Rust traits
- Rust generics tutorial
- Rust trait bounds
- Rust generic programming
- Rust interfaces
Traits & Generics in Rust
Traits and generics are the foundation of abstraction in Rust. Traits define shared behavior -- similar to interfaces in other languages, except that an interface has to be declared on the implementing class whereas your own trait can be implemented for a type you did not write -- while generics let you write code that works with many types. Together, they let you write flexible, reusable, type-safe code whose abstraction an optimizing build can collapse away — a property worth stating precisely rather than as a slogan, which this lesson does where the cost claims appear.
What You'll Learn
By the end of this lesson you will take three unrelated types that all need to answer the same question, give them one shared contract, and then write the code that uses them three different ways — once with a trait bound, once with a boxed trait object, and once returning a type the caller never learns the name of. The part that catches people is choosing between the first two, because the syntax of both mentions the trait and only one of them can hold a mixed collection.
You arrive here able to hang methods off your own types with an impl block (Structs & Enums) and to write a function that takes a reference (Functions). What is new is that the caller stops naming a concrete type. This is also the lesson that makes the next one legible: Iterator is a trait, every adapter you are about to meet is a generic function with a trait bound, and Display — the trait behind every {} you have ever written in a println! — is what the capstone's taskwork goes through every time it prints a task. You cannot read the chains that build its output until you can read a bound.
Defining a Trait
Start from something you already wrote. In Collections you built a gradebook: group scores into a HashMap<&str, Vec<f64>>, sort the keys so the report is stable, then for each key sum the values, divide by the count and print a formatted row. Now imagine the next report — same grouping, same sorting, same per-key summary, but over durations instead of scores, or over counts of u32 instead of f64. Copy the loop and change f64 to u32 and it works. Copy it a third time and you have three near-identical bodies whose only difference is the element type and the one line that formats a row, and a bug fixed in one of them stays alive in the other two.
That is the pressure this lesson answers, and it has two halves that need two different tools. Traits name the thing the three copies actually disagree about — "this type can be summarised into a row" — so the loop can be written once against the name instead of against f64. Generics let that single loop accept whichever concrete type turns up, without the compiler losing track of which one it is. Neither is worth reaching for until the duplication exists; you have now written it, so read the rest of this lesson as removing your own repetition rather than as a tour of syntax.
A trait declares a set of methods that a type must implement. Think of it as a contract: any type that implements the trait promises to provide those methods:
trait Summary {
fn summarize(&self) -> String;
// Default implementation -- types can override this
fn preview(&self) -> String {
format!("{}...", &self.summarize()[..20.min(self.summarize().len())])
}
}
struct Article {
title: String,
author: String,
content: String,
}
struct Tweet {
username: String,
body: String,
}
impl Summary for Article {
fn summarize(&self) -> String {
format!("{} by {} -- {}", self.title, self.author, &self.content[..50.min(self.content.len())])
}
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("@{}: {}", self.username, self.body)
}
}
fn main() {
let article = Article {
title: String::from("Rust 2024 Edition Released"),
author: String::from("The Rust Team"),
content: String::from("The Rust programming language has released its 2024 edition with many improvements."),
};
let tweet = Tweet {
username: String::from("rustlang"),
body: String::from("Exciting news for the Rust community!"),
};
println!("Article: {}", article.summarize());
println!("Tweet: {}", tweet.summarize());
}
Notice that Summary supplies a body for preview but not for summarize. That is the shape of most useful traits: a small set of methods each type must write, plus derived behaviour written once and inherited by everyone. Article and Tweet both take the default preview here without saying anything at all.
Implementing Standard Library Traits
Rust's standard library defines many useful traits. Implementing them lets your types integrate naturally with the language:
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
struct Color {
r: u8,
g: u8,
b: u8,
}
// Implement Display for human-readable output
impl fmt::Display for Color {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "#{:02X}{:02X}{:02X}", self.r, self.g, self.b)
}
}
fn main() {
let red = Color { r: 255, g: 0, b: 0 };
let also_red = red.clone();
let blue = Color { r: 0, g: 0, b: 255 };
// Display trait gives us to_string() and works with println!
println!("Red: {}", red);
println!("Blue: {}", blue);
// Debug trait gives us {:?} formatting
println!("Debug: {:?}", red);
// PartialEq gives us == and !=
println!("red == also_red: {}", red == also_red);
println!("red == blue: {}", red == blue);
}
Generic Functions
Generics let you write a function once that works with many types. The compiler generates specialized code for each type used — a separate copy of the function per concrete type, with every method call resolved at compile time. That specialization is what lets an optimizing build inline the calls and produce the same machine code you would have written by hand. Without optimizations it does not: the specialized copies are real, un-inlined function calls, and the Run button on this page compiles with no optimizations at all. Timed on that lane, summing a slice through a generic function took between 1.5 and 2.1 times as long as the same loop written out longhand, across nine runs. Treat that spread, not the midpoint, as the result: it is a shared machine and a short benchmark, so the exact multiplier is yours to measure rather than mine to promise. The direction is the part that is stable and the part that matters — if you time this yourself and find the abstraction is not free, your measurement is right, and it is the release-build claim that needs the qualifier.
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in &list[1..] {
if item > largest {
largest = item;
}
}
largest
}
fn first_and_last<T: std::fmt::Debug>(items: &[T]) -> Option<(&T, &T)> {
if items.is_empty() {
None
} else {
Some((&items[0], &items[items.len() - 1]))
}
}
fn main() {
let numbers = vec![34, 50, 25, 100, 65];
println!("Largest number: {}", largest(&numbers));
let chars = vec!['y', 'm', 'a', 'q'];
println!("Largest char: {}", largest(&chars));
let words = vec!["apple", "zebra", "mango"];
println!("Largest word: {}", largest(&words));
if let Some((first, last)) = first_and_last(&numbers) {
println!("First: {:?}, Last: {:?}", first, last);
}
}
Trait Bounds
Trait bounds constrain generic types so you can only use types that provide the behavior you need. There are two equivalent syntaxes:
use std::fmt;
// Syntax 1: Inline trait bound
fn print_labeled<T: fmt::Display>(label: &str, value: T) {
println!("{}: {}", label, value);
}
// Syntax 2: where clause (cleaner for complex bounds)
fn debug_pair<T, U>(first: T, second: U)
where
T: fmt::Debug + Clone,
U: fmt::Debug + fmt::Display,
{
println!("Debug: {:?} and {:?}", first, second);
println!("Display second: {}", second);
let _cloned = first.clone();
}
// Multiple trait bounds with +
fn compare_and_display<T: PartialOrd + fmt::Display>(a: T, b: T) {
if a > b {
println!("{} is greater than {}", a, b);
} else if a < b {
println!("{} is less than {}", a, b);
} else {
println!("{} equals {}", a, b);
}
}
fn main() {
print_labeled("Name", "Rust");
print_labeled("Version", 2024);
debug_pair(vec![1, 2, 3], "hello");
compare_and_display(10, 20);
compare_and_display(3.14, 2.71);
compare_and_display("apple", "banana");
}
impl Trait Syntax
The impl Trait syntax provides a convenient shorthand. In function parameters, it is syntactic sugar for a generic with a trait bound. In return position, it lets you return a concrete type without naming it:
// In parameter position: shorthand for generics
fn notify(item: &impl std::fmt::Display) {
println!("Breaking news: {}", item);
}
// In return position: return some type that implements the trait
fn make_greeting(name: &str) -> impl std::fmt::Display {
format!("Hello, {}! Welcome to Rust.", name)
}
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
fn main() {
notify(&"Rust is awesome");
notify(&42);
let greeting = make_greeting("Alice");
println!("{}", greeting);
let add_five = make_adder(5);
println!("5 + 3 = {}", add_five(3));
println!("5 + 10 = {}", add_five(10));
}
Generic Structs and Implementations
Structs can be generic over one or more type parameters. You can then write implementations that apply to all types or only to specific ones.
The fence below has two impl blocks for one struct, which is the point of it. The first is annotated. The second carries no note on purpose — before you read past the fence, say why larger and display could not have been put in the first block alongside new and into_tuple.
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}
// Unbounded: T is any type at all. These two methods only MOVE values
// around — they never compare or print one — so they demand nothing of T
// and are available on every Pair<T> that can be built.
impl<T> Pair<T> {
fn new(first: T, second: T) -> Self {
Pair { first, second }
}
fn into_tuple(self) -> (T, T) {
(self.first, self.second)
}
}
impl<T: std::fmt::Display + PartialOrd> Pair<T> {
fn larger(&self) -> &T {
if self.first >= self.second {
&self.first
} else {
&self.second
}
}
fn display(&self) {
println!("({}, {})", self.first, self.second);
}
}
fn main() {
let int_pair = Pair::new(10, 20);
int_pair.display();
println!("Larger: {}", int_pair.larger());
let str_pair = Pair::new("hello", "world");
str_pair.display();
println!("Larger: {}", str_pair.larger());
let (a, b) = Pair::new(3.14, 2.71).into_tuple();
println!("Unpacked: {} and {}", a, b);
}
The answer, in case you want to check it: those two methods do demand something of T. larger uses >=, which only exists for a type implementing PartialOrd, and display interpolates with {}, which only exists for a type implementing Display. Putting them in the unbounded block would not compile, because inside impl<T> Pair<T> the compiler knows nothing about T beyond its existence. The bound is not decoration on the block — it is what buys the two operations, and it is paid for by narrowing which Pair<T>s get the methods at all. A Pair<SomeStructWithNoDisplay> still gets new and into_tuple and simply does not have larger; nothing is broken, the second block just does not apply. Bounds are a per-block price, not a per-struct one, and this is the shape you reach for whenever a capability is worth having but not worth requiring of everyone.
Trait Objects for Dynamic Dispatch
When you need a collection of different types that share a trait, use trait objects with dyn. This uses dynamic dispatch (a vtable lookup at runtime) instead of static dispatch:
trait Drawable {
fn draw(&self);
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Drawable for Circle {
fn draw(&self) {
println!("Drawing circle with radius {:.1}", self.radius);
}
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Drawable for Rectangle {
fn draw(&self) {
println!("Drawing rectangle {}x{}", self.width, self.height);
}
fn area(&self) -> f64 {
self.width * self.height
}
}
fn print_total_area(shapes: &[Box<dyn Drawable>]) {
let total: f64 = shapes.iter().map(|s| s.area()).sum();
println!("Total area: {:.2}", total);
}
fn main() {
let shapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Rectangle { width: 4.0, height: 6.0 }),
Box::new(Circle { radius: 3.0 }),
];
for shape in &shapes {
shape.draw();
println!(" Area: {:.2}", shape.area());
}
print_total_area(&shapes);
}
The two dispatch strategies produce the same answers, so it is easy to treat the choice as a matter of taste. It is not — they differ in what the compiler emits and in what a reference to them physically is. The program below calls the same method through both routes and then measures the two reference types with size_of_val. Predict all three numbers before you run it:
Predict
The same Square is passed to a generic function (static dispatch) and to a &dyn Shape function (dynamic dispatch), and both return the same area. Then the program prints the size of &Square, of &dyn Shape, and of Square itself. On a 64-bit target a plain reference is 8 bytes. Predict all three sizes.
use std::mem::size_of_val;
trait Shape {
fn area(&self) -> f64;
}
struct Square { side: f64 }
impl Shape for Square {
fn area(&self) -> f64 { self.side * self.side }
}
// Static dispatch: the compiler generates one copy of this per concrete T.
fn area_static<T: Shape>(s: &T) -> f64 { s.area() }
// Dynamic dispatch: one copy, and the method is found through a vtable.
fn area_dyn(s: &dyn Shape) -> f64 { s.area() }
fn main() {
let sq = Square { side: 3.0 };
let plain: &Square = &sq;
let object: &dyn Shape = &sq;
println!("area_static: {}", area_static(&sq));
println!("area_dyn: {}", area_dyn(&sq));
println!("size of &Square: {}", size_of_val(&plain));
println!("size of &dyn Shape: {}", size_of_val(&object));
println!("size of Square: {}", size_of_val(&sq));
}Both calls print 9 — the same method really does run either way. The difference is how the call is found. area_static is monomorphized: the compiler stamps out a copy specialized to Square with the call address baked into the machine code, so a &Square is an ordinary one-word pointer (8 bytes). area_dyn is compiled once for every shape that will ever exist, so a &dyn Shape must carry the method table along with it — it is a fat pointer, one word for the data and one for the vtable, hence 16 bytes. That is the entire tradeoff in one measurement: static dispatch spends code size and buys inlining, dynamic dispatch spends a pointer per reference and an indirect call, and buys the ability to keep different types in one collection.
That last clause is the one with teeth, and the program below gets it wrong in the most natural way possible. It is trying to price an order containing three different item types, and it does not compile. Commit a hypothesis about which line the compiler will object to before you change anything:
Debug
This checkout routine should print a label for each of three different item types and total their prices to 1105 cents. It does not compile. The trait, the three impls and the body of checkout are all correct — say which line rustc rejects and why, then fix it.
// A menu prices items of several different types through one trait.
trait Priced {
fn cents(&self) -> u32;
// Default label; a type may override it.
fn label(&self) -> String {
format!("item ({} cents)", self.cents())
}
}
struct Coffee;
struct Tea;
struct Cake;
impl Priced for Coffee {
fn cents(&self) -> u32 { 350 }
fn label(&self) -> String { format!("Coffee ({} cents)", self.cents()) }
}
impl Priced for Tea {
fn cents(&self) -> u32 { 275 }
}
impl Priced for Cake {
fn cents(&self) -> u32 { 480 }
fn label(&self) -> String { format!("Cake ({} cents)", self.cents()) }
}
// Print every item's label and return the total price.
fn checkout<T: Priced>(items: &[T]) -> u32 {
let mut total = 0;
for item in items {
println!("{}", item.label());
total += item.cents();
}
total
}
fn main() {
let order = vec![Coffee, Tea, Cake];
let total = checkout(&order);
assert_eq!(total, 1105, "expected the three items to total 1105 cents, got {}", total);
println!("total: {} cents", total);
}Expected output: Coffee (350 cents)
item (275 cents)
Cake (480 cents)
total: 1105 cents
The compiler points at the vec!, not at checkout. A generic parameter resolves to one concrete type per call, so items: &[T] is a slice of a single type and vec![Coffee, Tea, Cake] has three — hence error[E0308]: mismatched types. A trait bound does not make a container heterogeneous; it constrains which single type is allowed to fill the slot. The fix is to erase the concrete types: Vec<Box<dyn Priced>> for the collection, Box::new(...) around each value, and fn checkout(items: &[Box<dyn Priced>]) -> u32 for the signature — the generic parameter vanishes entirely, because there is no longer one type to name. Not a character of the body changes. And notice Tea prints item (275 cents) in the fixed output: it never overrides label, so it inherits the trait's default. Default methods behave identically through either dispatch route.
Reading a Diagnostic That Points at the Wrong Line
That error is worth reading properly, because it is the standard case of a message whose location is correct and whose subject is somewhere else. Here it is exactly as the lane prints it:
error[E0308]: mismatched types
--> /tmp/main.rs:40:30
|
40 | let order = vec![Coffee, Tea, Cake];
| ^^^ expected `Coffee`, found `Tea`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.
Take it a part at a time. error[E0308] is a stable identifier, not decoration — it means "mismatched types" in every Rust program ever compiled, and rustc --explain E0308 prints the general write-up. The --> line gives the location: line 40, column 30. The ^^^ underline marks the exact token the compiler objected to, and the label on it names both sides of the comparison: expected Coffee, found Tea. Read that label as a small piece of reasoning rather than a verdict — the compiler is telling you how it arrived at Coffee, which was by looking at the first element and taking it as the type the whole vec! must be.
Now the part the message cannot tell you. The line it points at is not the line to change. Nothing is wrong with wanting three item types in one order; the constraint that made it illegal was written ten lines earlier, in fn checkout<T: Priced>(items: &[T]), which quietly requires a single T. Line 40 is merely where that requirement was first violated. This is a common and specific shape — a constraint declared in one place, reported at the first place it fails — and the routine for it is to read the underlined expression, ask what demanded that type, and go looking there.
Notice, too, what is absent: there is no help: line. rustc offers suggestions when there is a mechanical repair, and here there is not — turning a bound into a trait object is a design change across a signature, a collection type and three call sites, which is beyond what a suggestion can propose. So the absence of help: is information: it usually means the fix is structural rather than local, and a message with no suggestion is the one most worth reading slowly rather than the one to skim.
Assembling the Dynamic Version
The fix described above is five lines inside a function body, and the order of those five is not a matter of taste. One of the pairs is held apart by something the borrow checker enforces rather than by a name, which is what makes it worth assembling by hand.
Here is the receipt type the last two lines build, so you are not ordering lines against a struct whose shape you have to guess. Priced, Coffee, Tea and Cake are the ones from the debug block above, unchanged — Tea is the one that never overrides label:
struct Receipt {
names: String,
total: u32,
}
impl std::fmt::Display for Receipt {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "receipt [{}]: {} cents", self.names, self.total)
}
}
Arrange the code
These five lines put the three item types in one basket behind a trait object, collect their labels, add their prices and print a receipt. The pieces are shuffled. Put them in the order that runs and prints the receipt line ending '1105 cents' — then answer what the order is really testing: three of the four gaps hold because a name is not in scope yet, but one holds for a different reason entirely. Which pair is it, what does into_iter do to basket that iter does not, and what does the compiler call the result?
println!("{}", receipt);let total: u32 = basket.into_iter().map(|it| it.cents()).sum();let names = basket.iter().map(|it| it.label()).collect::<Vec<String>>().join(" + ");let basket: Vec<Box<dyn Priced>> = vec![Box::new(Coffee), Box::new(Tea), Box::new(Cake)];let receipt = Receipt { names, total };
The Same Contract in TypeScript
A trait is a named contract a type satisfies, and most typed languages have something answering that description. TypeScript's is the interface, and the resemblance goes further than it does with most — but it does not go all the way:
Transfer
TypeScript interfaces and Rust traits both name a set of methods and let a function accept any type providing them. The two snippets below are as close as the languages get. Which statement names what genuinely transfers between them, rather than a resemblance that breaks the first time you rely on it?
// The Rust half, complete and runnable. The TypeScript it is compared with
// is the same three declarations, and nothing in it names the interface:
// interface Priced { cents(): number; }
// class Coffee { cents(): number { return 350; } } // no "implements"
// function total(items: Priced[]): number { ... }
trait Priced {
fn cents(&self) -> u32;
}
struct Coffee;
struct Cake;
// Rust needs these blocks to exist. Without one, the type is not Priced, however
// many inherent methods called cents it happens to have.
impl Priced for Coffee {
fn cents(&self) -> u32 {
350
}
}
impl Priced for Cake {
fn cents(&self) -> u32 {
480
}
}
fn total(items: &[Box<dyn Priced>]) -> u32 {
items.iter().map(|i| i.cents()).sum()
}
fn main() {
// Two different types in one collection, so they must be boxed.
// The TypeScript equivalent is just: const basket = [new Coffee(), new Cake()];
let basket: Vec<Box<dyn Priced>> = vec![Box::new(Coffee), Box::new(Cake)];
println!("{}", total(&basket));
}Before assembling all of this, pull one prerequisite back out of memory. Everything above hangs impl blocks off types you first met several lessons ago, and the distinction between the two kinds of impl block matters for what follows:
Recall
Without scrolling up: in Structs & Enums you wrote methods on a struct using a plain impl TypeName { ... } block, before any trait existed. This lesson writes impl TraitName for TypeName { ... }. Which statement correctly distinguishes the two?
The impl Type { ... } block from Structs & Enums defines inherent methods: a type's own vocabulary, answering to no contract. impl Trait for Type { ... } declares that the type satisfies a named, shared contract. The consequence that matters for everything above is this: a generic function bounded by T: Priced, and a &dyn Priced trait object, can each see only the trait's methods, because the trait is the whole of what they know about the type. An inherent method, however useful, is invisible from behind an abstraction — which is why designing a trait is really deciding which capabilities abstract code is allowed to depend on.
Try It Yourself
Reading about the three abstraction tools is not the same as picking the right one under pressure. This is a build task: a small program that reports its own pass/fail. You are given a notification system — a Channel trait with one required method and one defaulted one, implemented by an Email and an Sms — and three stubbed functions, each demanding a different one of the tools this lesson taught. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
Each signature is already written and each one is a hint about which tool it wants: send_one<T: Channel> is a trait bound and can only ever see one concrete type; preview_all(&[Box<dyn Channel>]) is a trait object list holding two different types at once; and make_banner(...) -> impl fmt::Display returns a value whose type the caller never learns. The checks are built so that Email exercises the trait's default preview while Sms exercises its override, so a solution that ignores the default is caught. You write only the three bodies.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each behaves. Run it as-is to see which check fails first, decide which abstraction tool that signature is asking for, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
use std::fmt;
// A tiny notification system. Every channel formats a message its own way.
trait Channel {
// Required: how this channel renders one message.
fn render(&self, message: &str) -> String;
// Default: a one-line preview, truncated to 19 characters.
// A channel may override this if it can do better.
fn preview(&self, message: &str) -> String {
let full = self.render(message);
if full.len() <= 19 {
full
} else {
format!("{}...", &full[..19])
}
}
}
struct Email {
to: String,
}
struct Sms;
impl Channel for Email {
fn render(&self, message: &str) -> String {
format!("To: {} | {}", self.to, message)
}
}
impl Channel for Sms {
fn render(&self, message: &str) -> String {
format!("SMS: {}", message)
}
fn preview(&self, message: &str) -> String {
format!("SMS[{}]", message.len())
}
}
// TODO 1: STATIC dispatch. Render message through the ONE concrete channel
// given. The bound T: Channel is already written for you; the compiler
// generates a separate copy of this function per concrete type.
fn send_one<T: Channel>(channel: &T, message: &str) -> String {
let _ = (channel, message);
String::new()
}
// TODO 2: DYNAMIC dispatch over a MIXED list. Return every channel's
// PREVIEW of message, in order. Note the parameter is Box<dyn Channel>,
// not a generic — the list holds two different concrete types at once.
fn preview_all(channels: &[Box<dyn Channel>], message: &str) -> Vec<String> {
let _ = (channels, message);
Vec::new()
}
struct Banner(String);
impl fmt::Display for Banner {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "*** {} ***", self.0)
}
}
// TODO 3: return a Banner wrapping text, WITHOUT naming Banner in the
// signature. The return type is already impl fmt::Display.
fn make_banner(text: &str) -> impl fmt::Display {
let _ = text;
Banner(String::new())
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
let email = Email { to: String::from("ada@example.com") };
let sms = Sms;
assert_eq!(
send_one(&email, "deploy finished"),
"To: ada@example.com | deploy finished",
"send_one should render through the channel it was given"
);
assert_eq!(
send_one(&sms, "deploy finished"),
"SMS: deploy finished",
"send_one must work for ANY Channel, not just Email"
);
let channels: Vec<Box<dyn Channel>> = vec![
Box::new(Email { to: String::from("ada@example.com") }),
Box::new(Sms),
];
assert_eq!(
preview_all(&channels, "deploy finished"),
vec![
String::from("To: ada@example.com..."),
String::from("SMS[15]"),
],
"preview_all should use each channel's own preview: Email inherits the default, Sms overrides it"
);
assert_eq!(
make_banner("release").to_string(),
"*** release ***",
"make_banner should return something that Displays as a banner"
);
println!("All checks passed.");
println!("{}", send_one(&email, "deploy finished"));
for p in preview_all(&channels, "deploy finished") {
println!("{}", p);
}
println!("{}", make_banner("release"));
}Expected output: All checks passed.
To: ada@example.com | deploy finished
To: ada@example.com...
SMS[15]
*** release ***
Once it passes, try two variations and predict each before running:
- Give the generic a mixed list. Change
send_oneto take a slice,fn send_one<T: Channel>(channels: &[T], message: &str), and call it withvec![Box::new(Email { .. }), Box::new(Sms)]. Predict whether this compiles before running. It does not: the elements areBox<Email>andBox<Sms>, two different types, and&[T]needs one — the sameerror[E0308]as the debug block above. This is the boundary between the two tools, and it has nothing to do with performance: a bound simply cannot express "any mixture of these". - Delete the
Smsoverride ofpreview. Remove the wholefn previewfromimpl Channel for Smsand predict whether this is a compile error or a check failure before running. It compiles perfectly — the trait's default quietly takes over — and thepreview_allcheck then fails with"SMS: deploy finishe..."where"SMS[15]"was expected: the default renders"SMS: deploy finished", finds it longer than 19 characters, and truncates. Removing an override is never a compile error, which is exactly why a defaulted method is a behaviour decision rather than just boilerplate reduction.
Key Takeaways
- Traits define shared behavior as a set of methods that types must implement
- Default method implementations reduce boilerplate when many types share common logic
- Generics let you write one function or struct that works with many types; with optimizations on, the specialized copies inline down to the code you would have written by hand, while an unoptimized build — including this page's Run button — leaves them as real function calls
- Trait bounds constrain generics so you can only use types that provide the required behavior
impl Traitprovides convenient shorthand in both parameter and return position- Use
dyn TraitwithBoxfor dynamic dispatch when you need collections of mixed types - Conditional implementations let you add methods only when type parameters satisfy certain bounds
Pro Tip: Choose between the two by what the code needs to express, not by a remembered cost ranking. Use
dyn Traitwhen you genuinely need a heterogeneous collection — a bound cannot express that at all, at any optimization level. Reach for generics when a single concrete type per call site is what you mean, and note that the dispatch difference is a release-build property: measure it in an optimized build before letting it decide a design, because the debug build behind the Run button is not where that comparison is meaningful.
Next Steps
Traits and generics are the foundation for one of Rust's most elegant abstractions: iterators. Next, we'll explore how the Iterator trait and its adapter methods let you process sequences of data with concise, composable chains.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.