08. Traits and Generics

📋 Jump to Takeaways

🎁 Imagine writing a function that works on ANY type that can be printed, compared, or cloned — without knowing what that type is when you write the code. Traits and generics make this possible, giving you polymorphism without inheritance and zero-cost abstractions without runtime overhead.

Defining a Trait

A trait defines a set of methods that a type must implement. Think of it as a contract — any type that signs the contract gains that capability.

trait Summary {
    fn summarize(&self) -> String;
}

This declares that any type implementing Summary must provide a summarize method that returns a String. The trait doesn't know or care what the type is internally.

Implementing Traits

You implement a trait for a specific type with an impl Trait for Type block.

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    author: String,
}

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

fn main() {
    let post = Article {
        title: String::from("Rust is Fast"),
        author: String::from("Alice"),
    };
    println!("{}", post.summarize()); // Rust is Fast by Alice
}

Each type provides its own logic. You can implement the same trait for completely different structs, and each gets its own behavior.

Default Methods

Traits can provide default implementations. Types can override them or use the default.

trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

struct Tweet {
    username: String,
    content: String,
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
    // Uses default summarize()
}

fn main() {
    let tweet = Tweet {
        username: String::from("rustlang"),
        content: String::from("Exciting news!"),
    };
    println!("{}", tweet.summarize());
    // (Read more from @rustlang...)
}

Default methods can call other methods in the same trait, even ones without defaults. This lets you require minimal implementation while providing rich functionality.

Traits as Parameters

You can write functions that accept any type implementing a specific trait using &impl Trait syntax.

trait Summary {
    fn summarize(&self) -> String;
}

fn notify(item: &impl Summary) {
    println!("Breaking: {}", item.summarize());
}

This function works with any type that implements Summary. The compiler generates specialized code for each concrete type you call it with — zero runtime cost.

Trait Bounds

The &impl Trait syntax is syntactic sugar for trait bounds, which give you more control.

fn notify<T: Summary>(item: &T) {
    println!("Breaking: {}", item.summarize());
}

// When you need two params of the SAME type:
fn compare<T: Summary>(a: &T, b: &T) {
    println!("{} vs {}", a.summarize(), b.summarize());
}

With &impl Summary, each parameter could be a different type. With <T: Summary>, you enforce that both parameters are the same type.

Multiple Bounds with +

You can require a type to implement multiple traits using +.

use std::fmt::{Display, Debug};

fn print_both<T: Display + Debug>(item: &T) {
    println!("Display: {}", item);   // Uses Display
    println!("Debug: {:?}", item);   // Uses Debug
}

fn main() {
    print_both(&42);
    // Display: 42
    // Debug: 42
}

The + syntax says "this type must implement Display AND Debug." You can stack as many bounds as needed.

Where Clauses

When bounds get complex, move them to a where clause for readability.

use std::fmt::{Display, Debug};

fn complex_function<T, U>(t: &T, u: &U) -> String
where
    T: Display + Clone,
    U: Debug + Default,
{
    format!("{} and {:?}", t, u)
}

fn main() {
    let result = complex_function(&"hello", &42);
    println!("{}", result); // hello and 42
}

The where clause keeps the function signature clean while expressing the same constraints. Use it when you have more than one or two bounds.

Returning impl Trait

You can return a type that implements a trait without naming the concrete type.

trait Summary {
    fn summarize(&self) -> String;
}

struct Article { title: String }

impl Summary for Article {
    fn summarize(&self) -> String {
        self.title.clone()
    }
}

fn create_summary() -> impl Summary {
    Article { title: String::from("New Discovery") }
}

fn main() {
    let item = create_summary();
    println!("{}", item.summarize()); // New Discovery
}

This is useful when the return type is complex or when you want to hide implementation details. The caller only knows it gets "something that implements Summary."

The Derive Macro

Rust can automatically implement common traits with #[derive]. This generates standard implementations at compile time.

#[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();

    println!("{:?}", p1);       // Point { x: 1.0, y: 2.0 }
    println!("{}", p1 == p2);   // true
}

#[derive] works for traits with obvious implementations. You can derive Debug, Clone, Copy, PartialEq, Eq, Hash, Default, and more.

Common Standard Library Traits

These traits appear everywhere in Rust code. Knowing them is essential.

use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Default)]
struct Color {
    r: u8,
    g: u8,
    b: u8,
}

// Display must be implemented manually
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 default = Color::default();

    println!("{}", red);        // #ff0000 (Display)
    println!("{:?}", red);      // Color { r: 255, g: 0, b: 0 } (Debug)
    println!("{:?}", default);  // Color { r: 0, g: 0, b: 0 } (Default)

    let copy = red; // Copy — no move!
    println!("{} == {}: {}", red, copy, red == copy); // #ff0000 == #ff0000: true
}
  • Display — user-facing formatting ({})
  • Debug — developer-facing formatting ({:?})
  • Clone — explicit deep copy (.clone())
  • Copy — implicit bitwise copy (small, stack-only types)
  • PartialEq — equality comparison (==, !=)
  • Default — provides a zero/empty value

Generics in Structs and Enums

You can parameterize structs and enums with type variables, making them work with any type.

#[derive(Debug)]
struct Pair<T> {
    first: T,
    second: T,
}

impl<T: PartialOrd + std::fmt::Display> Pair<T> {
    fn larger(&self) -> &T {
        if self.first >= self.second {
            &self.first
        } else {
            &self.second
        }
    }
}

fn main() {
    let int_pair = Pair { first: 5, second: 10 };
    println!("Larger: {}", int_pair.larger()); // Larger: 10

    let str_pair = Pair { first: "apple", second: "banana" };
    println!("Larger: {}", str_pair.larger()); // Larger: banana
}

Rust's Option<T> and Result<T, E> are generic enums you've already used. Generics enable code reuse without sacrificing type safety or performance.

// You've been using generics all along!
enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

The compiler generates specialized versions for each concrete type — this is called monomorphization. You get the flexibility of generics with the speed of hand-written code.

Key Takeaways

  • Traits define shared behavior — they're Rust's answer to interfaces
  • impl Trait for Type provides the concrete implementation
  • Default methods let you offer functionality with minimal required implementation
  • &impl Trait and <T: Trait> accept any type satisfying the bound
  • Use + for multiple bounds and where clauses for complex signatures
  • -> impl Trait hides the concrete return type behind a trait contract
  • #[derive] auto-generates common trait implementations at compile time
  • Generics in structs and enums let one definition work with many types
  • Monomorphization means generics have zero runtime cost — the compiler generates specialized code

🎁 Traits and generics let you write flexible, reusable code — but what happens when you return a reference from a function? How does the compiler know the reference is still valid? Next up: lifetimes give the compiler proof that a reference won't outlive the data it points to.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy