Step 08 of 17
Traits & generics
Define shared behaviour with traits, write generic code with bounds, and choose between impl Trait, generics and dyn Trait.
Traits are Rust's way of describing what a type can do, and generics let you write code once for every type that can do it. Together they replace interfaces, abstract classes and templates from other languages, with one important twist: by default, generic code is compiled separately for each concrete type, so abstraction costs nothing at runtime. This lesson covers defining and implementing traits, generic functions and structs, trait bounds, the standard traits you'll derive and implement constantly, and when to use dynamic dispatch with dyn Trait instead.
Defining and implementing traits
A trait declares methods a type must provide. It can also provide default implementations:
trait Summary {
fn author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.author()) // default, uses the required method
}
}
struct Post {
title: String,
author: String,
}
struct Tweet {
username: String,
text: String,
}
impl Summary for Post {
fn author(&self) -> String {
self.author.clone()
}
fn summarize(&self) -> String {
format!("{}, by {}", self.title, self.author) // overrides the default
}
}
impl Summary for Tweet {
fn author(&self) -> String {
format!("@{}", self.username)
}
// uses the default summarize
}
fn main() {
let p = Post { title: "Ownership explained".into(), author: "Ana".into() };
let t = Tweet { username: "ferris".into(), text: "hi".into() };
println!("{}\n{} ({} chars)", p.summarize(), t.summarize(), t.text.len());
}Unlike Go's implicit interfaces, implementation is explicit: you write impl Summary for Post. That makes it searchable, and it means a type can't satisfy a trait by accident just because a method name matches.
You can implement your own traits for any type, including standard ones like i32 or Vec<T>, and standard traits for your own types. What you can't do is implement a foreign trait for a foreign type (say, Display for Vec<u8>). This orphan rule guarantees that two crates can never provide conflicting implementations. The workaround is a newtype: struct Hex(Vec<u8>); and impl Display for Hex.
Generic functions and trait bounds
A generic function takes type parameters in angle brackets. Trait bounds say what those types must be able to do:
fn largest<T: PartialOrd>(items: &[T]) -> Option<&T> {
let mut best = items.first()?;
for item in items {
if item > best {
best = item;
}
}
Some(best)
}
fn main() {
println!("{:?}", largest(&[3, 9, 2])); // Some(9)
println!("{:?}", largest(&["kiwi", "apple"])); // Some("kiwi")
println!("{:?}", largest::<f64>(&[])); // None
}Without T: PartialOrd, the compiler rejects item > best: it type-checks the generic body once, against the bounds, not against each caller's type. That's why generic errors in Rust show up where you write the function, not deep inside a caller's instantiation as with C++ templates.
Bounds get long, so there are three ways to write them:
use std::fmt::{Debug, Display};
// 1. Inline
fn show_a<T: Display + Clone>(x: T) -> String { format!("{x}") }
// 2. where clause: easier to read with several parameters
fn show_b<T, U>(t: T, u: U) -> String
where
T: Display + Clone,
U: Debug,
{
format!("{t} {u:?}")
}
// 3. impl Trait in argument position: shorthand for an anonymous generic
fn show_c(x: impl Display) -> String { format!("{x}") }Monomorphization
When you call largest with &[i32] and &[&str], the compiler generates two copies of the function, each specialized for its type. This is monomorphization. The result is as fast as hand-written code for each type, with full inlining. The cost is compile time and binary size, which grow with the number of distinct instantiations.
Generic structs and impl blocks
Structs and enums can be generic too; you've already used Vec<T>, Option<T> and Result<T, E>.
#[derive(Debug)]
struct Pair<T> {
a: T,
b: T,
}
impl<T> Pair<T> {
fn new(a: T, b: T) -> Self {
Self { a, b }
}
}
// Methods that only exist when T supports comparison and printing.
impl<T: PartialOrd + std::fmt::Display> Pair<T> {
fn print_max(&self) {
let max = if self.a >= self.b { &self.a } else { &self.b };
println!("max is {max}");
}
}
fn main() {
Pair::new(3, 8).print_max();
let p = Pair::new(vec![1], vec![2]); // fine: new has no bounds
println!("{p:?}"); // but p.print_max() wouldn't compile: Vec isn't Display
}You can also implement a trait for every type that satisfies a bound. This is a blanket implementation, and the standard library uses it heavily: impl<T: Display> ToString for T is why every displayable type has .to_string().
Associated types
Some traits have a type that belongs to the implementation rather than being chosen by the caller. Iterator is the classic case:
struct Countdown(u32);
impl Iterator for Countdown {
type Item = u32; // each implementation picks exactly one Item type
fn next(&mut self) -> Option<Self::Item> {
if self.0 == 0 {
None
} else {
self.0 -= 1;
Some(self.0 + 1)
}
}
}
fn main() {
let v: Vec<u32> = Countdown(3).collect();
println!("{v:?}"); // [3, 2, 1]
}Implementing one method, next, gives Countdown every iterator adapter for free: map, filter, sum, collect and the rest come from default methods on the trait.
Use an associated type when there's one natural choice per implementing type (an iterator yields one kind of item). Use a generic parameter (trait From<T>) when a type can implement the trait many times for different Ts.
The standard traits
You'll derive or implement these constantly:
| Trait | Gives you | Usually |
|---|---|---|
Debug | {:?} formatting | #[derive] on nearly everything |
Clone / Copy | .clone() / implicit copies | Derive; Copy only for small plain data |
PartialEq / Eq | == and != | Derive; Eq when equality is total (not floats) |
PartialOrd / Ord | <, >, sorting | Derive, or implement for custom orderings |
Hash | Use as a HashMap key | Derive together with Eq |
Default | T::default(), ..Default::default() | Derive, or implement for meaningful defaults |
Display | {} formatting, .to_string() | Implement by hand for user-facing text |
From / Into | Conversions; ? uses From | Implement From; Into comes free |
Drop | Cleanup on scope end | Implement for resource owners |
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
struct Version {
major: u32,
minor: u32,
patch: u32,
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)
}
}
fn main() {
let mut vs = vec![
Version { major: 1, minor: 10, patch: 0 },
Version { major: 1, minor: 2, patch: 7 },
Version::default(),
];
vs.sort(); // derived Ord compares fields in declaration order
let names: Vec<String> = vs.iter().map(|v| v.to_string()).collect();
println!("{names:?}"); // ["v0.0.0", "v1.2.7", "v1.10.0"]
}Dynamic dispatch with dyn Trait
Generics need every type known at compile time. Sometimes you need a collection of different types that share a trait, or you want to choose an implementation at runtime. That's a trait object, dyn Trait, always behind a pointer such as Box<dyn Trait> or &dyn Trait:
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str;
}
struct Circle(f64);
struct Square(f64);
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.0 * self.0 }
fn name(&self) -> &str { "circle" }
}
impl Shape for Square {
fn area(&self) -> f64 { self.0 * self.0 }
fn name(&self) -> &str { "square" }
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle(1.0)), Box::new(Square(2.0))];
for s in &shapes {
println!("{} {:.2}", s.name(), s.area());
}
let total: f64 = shapes.iter().map(|s| s.area()).sum();
println!("{total:.2}");
}A Box<dyn Shape> is a fat pointer: a pointer to the data plus a pointer to a vtable of the concrete type's methods. Each call looks up the method in the vtable at runtime. That's what Java, C# and Go interfaces do for every call.
Generics (T: Trait, impl Trait) | Trait objects (dyn Trait) | |
|---|---|---|
| Dispatch | Static: resolved at compile time, inlinable | Dynamic: vtable lookup per call |
| Mixed types in one collection | No | Yes |
| Binary size | One copy per type used | One copy |
| Runtime cost | None | An indirect call; blocks inlining |
| Restrictions | None | Trait must be dyn compatible (no generic methods, no Self returns) |
Default to generics. Reach for dyn Trait when you need heterogeneous collections, plugin-style registries, or to keep compile times and binary size down for code that isn't hot. The static vs dynamic dispatch concept covers the trade-off in more depth.
impl Trait in return position
-> impl Trait returns some concrete type the caller doesn't get to name. It's still static dispatch, and it's how you return closures and iterator chains whose real types are unspeakable:
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0)
}
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
fn main() {
println!("{:?}", evens(10).collect::<Vec<_>>());
println!("{}", make_adder(5)(10));
}The function must return a single concrete type on every path. If different branches return different types, you need Box<dyn Trait>.
Practice
Build a tiny notification system.
- Define
trait Notifier { fn send(&self, to: &str, msg: &str) -> Result<(), String>; fn name(&self) -> &'static str { "notifier" } }. - Implement it for
struct Email { from: String }andstruct Sms { sender_id: u32 }, overridingname. - Write a generic function
fn notify_all<N: Notifier>(n: &N, users: &[&str], msg: &str) -> usizethat returns how many sends succeeded. MakeSmsfail for any number that doesn't start with+. - Write a
struct Dispatcher { channels: Vec<Box<dyn Notifier>> }withfn broadcast(&self, to: &str, msg: &str)that tries every channel and prints the result for each. - Explain why
notify_allcan't be used to store both notifiers in oneVec, and whyDispatchercan.
Show solution
trait Notifier {
fn send(&self, to: &str, msg: &str) -> Result<(), String>;
fn name(&self) -> &'static str {
"notifier"
}
}
struct Email {
from: String,
}
struct Sms {
sender_id: u32,
}
impl Notifier for Email {
fn send(&self, to: &str, msg: &str) -> Result<(), String> {
println!("email {} -> {to}: {msg}", self.from);
Ok(())
}
fn name(&self) -> &'static str {
"email"
}
}
impl Notifier for Sms {
fn send(&self, to: &str, msg: &str) -> Result<(), String> {
if !to.starts_with('+') {
return Err(format!("{to} is not a phone number"));
}
println!("sms #{} -> {to}: {msg}", self.sender_id);
Ok(())
}
fn name(&self) -> &'static str {
"sms"
}
}
fn notify_all<N: Notifier>(n: &N, users: &[&str], msg: &str) -> usize {
users.iter().filter(|u| n.send(u, msg).is_ok()).count()
}
struct Dispatcher {
channels: Vec<Box<dyn Notifier>>,
}
impl Dispatcher {
fn broadcast(&self, to: &str, msg: &str) {
for ch in &self.channels {
match ch.send(to, msg) {
Ok(()) => println!("[{}] sent", ch.name()),
Err(e) => println!("[{}] failed: {e}", ch.name()),
}
}
}
}
fn main() {
let sms = Sms { sender_id: 7 };
println!("{}", notify_all(&sms, &["+4512345678", "[email protected]"], "hi")); // 1
let d = Dispatcher {
channels: vec![Box::new(Email { from: "[email protected]".into() }), Box::new(sms)],
};
d.broadcast("+4512345678", "deploy finished");
}notify_all::<Sms> and notify_all::<Email> are two separate monomorphized functions; each call works with exactly one concrete type, so it can't hold a mix. Vec<Box<dyn Notifier>> erases the concrete type behind a vtable pointer, so each element can be a different type and calls are dispatched at runtime.
Key takeaways
- A trait lists methods a type must implement, optionally with defaults; implementations are explicit, and the orphan rule prevents conflicts between crates.
- Generic code is checked against its bounds once and monomorphized per type: zero runtime cost, more compile time and binary size.
- Use associated types when each implementation has one natural choice (like
Iterator::Item). - Derive the standard traits (
Debug,Clone,PartialEq,Hash,Default…) and implementDisplayandFromby hand. - Prefer generics and
impl Trait; useBox<dyn Trait>for heterogeneous collections and runtime choice, at the cost of a vtable call.
Check yourself
Quiz
0/4 correct · 4 left
1.Why does fn max<T>(a: T, b: T) -> T { if a > b { a } else { b } } fail to compile?
2.You need a Vec holding both FileLogger and HttpLogger values that implement Logger. Which type works?
3.What does monomorphization mean for a generic function called with three different types?
4.Why can't you write impl std::fmt::Display for Vec<u8> in your crate?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.