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 enable you to write flexible, reusable, and type-safe code without sacrificing performance.
Defining a Trait
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, so there is no runtime cost:
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:
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}
impl<T> Pair<T> {
fn new(first: T, second: T) -> Self {
Pair { first, second }
}
fn into_tuple(self) -> (T, T) {
(self.first, self.second)
}
}
// This impl block only applies when T implements Display + PartialOrd
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);
}
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.
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 zero runtime cost
- 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: Prefer static dispatch (generics) when performance matters and you know the types at compile time. Use dynamic dispatch (
dyn Trait) when you need a heterogeneous collection or want to reduce compile times in large codebases.
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.
Next lesson
Iterators
Master Rust's powerful iterator system to write expressive, efficient, and functional-style code
25 min