Lesson 11: Traits and Generics
Introduction
You’ve built structs, enums, and handled errors — now it’s time to learn how to write code that’s flexible and reusable. Rust gives you two powerful tools for this: traits and generics.
- A trait defines shared behaviour that different types can implement — like an interface or protocol in other languages.
- A generic lets you write a function or struct that works with many different types without repeating yourself.
Together, they let you write code that is both flexible (works with many types) and safe (checked at compile time — no runtime surprises).
Defining a Trait
A trait declares one or more method signatures. Any type that wants to use the trait must implement those methods. Think of it as a promise: “I can do this.”
trait Greet {
fn hello(&self) -> String;
}
struct Dog;
struct Cat;
impl Greet for Dog {
fn hello(&self) -> String {
String::from("Woof!")
}
}
impl Greet for Cat {
fn hello(&self) -> String {
String::from("Meow!")
}
}
fn main() {
let d = Dog;
let c = Cat;
println!("{}", d.hello());
println!("{}", c.hello());
}trait Greetdefines the contract: any type that implements it must have ahellomethod.impl Greet for Dogandimpl Greet for Catfulfil that contract for their respective types.- Both
DogandCatcan now call.hello().
Default Implementations
Traits can provide a default implementation for a method. Types can use the default or override it — or both.
trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
struct Article {
title: String,
}
struct Tweet {
content: String,
}
impl Summary for Article {}
impl Summary for Tweet {
fn summarize(&self) -> String {
self.content.clone()
}
}
fn main() {
let a = Article { title: String::from("Rust is awesome") };
let t = Tweet { content: String::from("Just wrote my first trait!") };
println!("{}", a.summarize());
println!("{}", t.summarize());
}Articleuses the defaultsummarize— the emptyimpl Summary for Article {}is valid.Tweetoverridessummarizewith its own version.- Default implementations save you from writing boilerplate while still allowing customisation.
Traits as Function Parameters
You can write functions that accept any type that implements a given trait using the impl Trait syntax. This is how Rust achieves polymorphism at compile time.
trait Area {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Area for Circle {
fn area(&self) -> f64 {
3.14159 * self.radius * self.radius
}
}
impl Area for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn print_area(shape: &impl Area) {
println!("Area: {:.2}", shape.area());
}
fn main() {
let c = Circle { radius: 5.0 };
let r = Rectangle { width: 4.0, height: 6.0 };
print_area(&c);
print_area(&r);
}fn print_area(shape: &impl Area) means: “give me a reference to anything that implements Area.” Both Circle and Rectangle qualify — no duplication needed.
Deriving Common Traits
Many standard traits can be automatically derived using the #[derive(...)] attribute. This is a shortcut that tells Rust to generate the implementation for you.
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: f64,
y: f64,
}
fn main() {
let p1 = Point { x: 1.0, y: 2.0 };
let p2 = p1.clone();
let p3 = Point { x: 1.0, y: 2.0 };
println!("p1 = {:?}", p1);
println!("p2 = {:?}", p2);
println!("p1 == p3: {}", p1 == p3);
}Debug— enables{:?}printing.Clone— enables.clone()to duplicate the value.PartialEq— enables==comparison between values.
Generics: Writing Flexible Code
Imagine you need a function that finds the largest value in a list — first for i32, then for f64. Without generics, you’d write two nearly identical functions:
fn largest_i32(list: &[i32]) -> i32 {
let mut max = list[0];
for &item in list {
if item > max {
max = item;
}
}
max
}
fn largest_f64(list: &[f64]) -> f64 {
let mut max = list[0];
for &item in list {
if item > max {
max = item;
}
}
max
}
fn main() {
let nums = vec![34, 50, 25, 100, 65];
println!("Largest i32: {}", largest_i32(&nums));
let floats = vec![3.14, 2.71, 1.41];
println!("Largest f64: {}", largest_f64(&floats));
}That’s a lot of repetition! Generics let you collapse both into one function. The playground link below shows the generic version — try it out:
trait Describable {
fn describe(&self) -> String;
}
struct Book {
title: String,
author: String,
}
struct Movie {
title: String,
year: u32,
}
// TODO: Implement Describable for Book
// Output should look like: "'The Rust Book' by Steve Klabnik"
// TODO: Implement Describable for Movie
// Output should look like: "'Ferris Bueller' (1986)"
fn main() {
let b = Book {
title: String::from("The Rust Book"),
author: String::from("Steve Klabnik"),
};
let m = Movie {
title: String::from("Ferris Bueller"),
year: 1986,
};
println!("{}", b.describe());
println!("{}", m.describe());
}
▶ Task 1: Implement Describable trait
trait Printable {
fn print(&self);
// Default implementation — no need to override this
fn print_twice(&self) {
self.print();
self.print();
}
}
struct Number(i32);
struct Label(String);
// TODO: Implement Printable for Number
// print() should output the number, e.g. "42"
// TODO: Implement Printable for Label
// print() should output the label text, e.g. "hello"
fn main() {
let n = Number(42);
let l = Label(String::from("hello"));
n.print_twice();
l.print_twice();
}
▶ Task 2: Default trait method
// Mini-challenge: Write a generic largest() function that works
// for any type T that supports comparison (PartialOrd).
// It should return a reference to the largest element in the slice.
fn largest<T: PartialOrd>(list: &[T]) -> &T {
// TODO: start with &list[0] as the current max
// Iterate through the rest; update max when you find something bigger
// Return the max reference
&list[0] // replace this
}
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!["rust", "go", "python", "zig"];
println!("Largest word: {}", largest(&words));
}
▶ Mini-challenge: Generic largest function
Implementing the Display Trait
You can implement std::fmt::Display to control how your type prints with {}. This is how Rust lets you customise output formatting.
use std::fmt;
struct Celsius(f64);
impl fmt::Display for Celsius {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:.1}°C", self.0)
}
}
fn main() {
let temp = Celsius(36.6);
println!("Body temperature: {}", temp);
}fmt::Display uses write! to format the output. The f parameter is the formatter — you don’t need to understand it fully right now, just know the pattern.
Multiple Trait Bounds
A parameter can require a type to implement multiple traits at once using the + syntax:
use std::fmt;
fn print_info(item: &(impl fmt::Display + fmt::Debug)) {
println!("Display: {}", item);
println!("Debug: {:?}", item);
}
fn main() {
let n = 42;
let s = String::from("hello");
print_info(&n);
print_info(&s);
}What You’ve Learned
- 🦀 Traits define shared behaviour — like interfaces in other languages.
- 🦀 Default implementations provide fallback behaviour that types can override.
- 🦀 impl Trait syntax lets functions accept any type that satisfies a trait.
- 🦀 #[derive] auto-generates common trait implementations.
- 🦀 Generics let you write one function or struct for many types.
- 🦀 Display lets you control how your types print with
{}. - 🦀 You can combine multiple trait bounds with
+.
Next up: Lesson 12 — Building a Command-Line App, where you’ll bring everything together in a real project!
Previous: Lesson 10: Modules and Packages | Next: Lesson 12: CLI App Project | Course Index