<< Home

Chapter 8: Traits, Generics, and Lifetimes

Traits define shared behavior. Generics let code work for many types while staying checked at compile time. Lifetimes describe how long references remain valid. Together these features enable reusable, safe abstractions.

Defining and implementing traits

A trait is a collection of required methods and optional default methods. Implement a trait for a type to make it satisfy that behavior. Traits can also be used as bounds for generics.

Declaring a trait

Start with a trait block. Provide method signatures. You can include default bodies that implementors may override.

pub trait Describe {
  fn name(&self) -> &str;
  fn describe(&self) -> String {
    format!("This is {}", self.name())
  }
}

Implementing a trait

Use impl Trait for Type. Implement all required methods, optionally override defaults.

struct User { id: u64, handle: String }

impl Describe for User {
  fn name(&self) -> &str { &self.handle }
  fn describe(&self) -> String {
    format!("user#{} ({})", self.id, self.name())
  }
}
⚠️ You can implement a trait for a type if either the trait or the type is local to your crate. This is the orphan rule which prevents conflicting implementations across crates.

Generic functions and types

Generics abstract over types while monomorphization generates efficient code for each concrete instantiation. Use type parameters inside angle brackets and refer to them in signatures and bodies.

Generic functions

Introduce type parameters with <T, U, …>. Add bounds to require behavior which will appear later in this chapter.

fn take_two<T>(a: T, b: T) -> (T, T) {
  (a, b)
}

Generic structs and enums

Define type parameters on the type and optionally on impl blocks.

struct Boxed<T>(T);

impl<T> Boxed<T> {
  fn new(value: T) -> Self { Boxed(value) }
  fn into_inner(self) -> T { self.0 }
}
💡 Use turbofish syntax ::<T> when inference cannot determine type parameters. Example: "42".parse::<i32>().

Trait bounds and where clauses

Bounds specify the required behavior for type parameters. Put simple bounds inline or move complex ones to a where clause for readability.

Inline bounds

Attach bounds directly to type parameters. The compiler then ensures only types implementing those traits can be used.

fn min<T: Ord>(a: T, b: T) -> T {
  if a <= b { a } else { b }
}

Readable where clauses

Move longer bounds into a dedicated clause. This helps when there are several parameters or multiple trait requirements.

use std::fmt::Display;

fn show_pair<A, B>(a: A, b: B)
where
  A: Display + Clone,
  B: Display,
{
  println!("a={}, b={}", a, b);
}
⚠️ Trait objects such as &dyn Display use dynamic dispatch. Generic functions with T: Display use static dispatch via monomorphization. Choose based on performance and object-safety needs.

Associated types vs generics

Associated types tie a placeholder type to a trait implementation. Generics parameterize the trait itself. Choose associated types when there is a single natural type per implementor. Choose generics when callers should select the type.

Using associated types

Define a placeholder within the trait, then specify it in each impl. Callers do not specify it at use sites.

trait IntoIter {
  type Item;

  fn into_iter(self) -> Box<dyn Iterator<Item = Self::Item>>;
}

impl IntoIter for String {
  type Item = char;

  fn into_iter(self) -> Box<dyn Iterator<Item = char>> {
    Box::new(self.chars())
  }
}

Using generics on traits

Parameterize the trait so callers pick the type argument. This is flexible when one implementor supports many output types.

trait Converter<T> {
  fn convert(&self, input: &str) -> Option<T>;
}

struct Parsers;

impl Converter<i32> for Parsers {
  fn convert(&self, input: &str) -> Option<i32> {
    input.parse().ok()
  }
}
💡 If the output type is intrinsic to the implementor, prefer an associated type. If users should choose, prefer a generic parameter.

Lifetime parameters and elision rules

Lifetimes describe the scope in which references remain valid. The compiler usually infers them. You only name lifetimes when inference needs help or when exposing relationships in public APIs.

Naming lifetimes

Use a tick prefix like 'a. Bind parameters to show that one reference cannot outlive another.

fn first<'a>(s: &'a str) -> &'a str {
  &s[..1]
}

Elision rules and common patterns

Rust applies rules that remove the need to write lifetimes in many cases. For example, a method with &self often elides the output lifetime to match self. For functions with multiple input references and one output reference, you may need to state relationships explicitly.

// Elision: &self implies output borrows from self
impl User {
  fn handle(&self) -> &str { &self.handle }
}

// Explicit: choose which input the output depends on
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
  if a.len() >= b.len() { a } else { b }
}
⚠️ Lifetimes never change runtime behavior. They are a static contract checked by the compiler which prevents dangling references while generating zero-cost code.