Generics and Traits
Learning Objectives
- You can write generic functions that work with multiple types.
- You understand how to create generic structs and enums.
- You can define traits to specify shared behavior.
- You know how to implement traits for your types.
- You understand trait bounds and how to constrain generic types.
- You understand the difference between static and dynamic dispatch.
- You can use trait objects with
dynto enable dynamic dispatch. - You can use common standard library traits.
Warming up
Here is a set of tiny programming tasks to get you familiar with syntax that relates to generics and traits. You can solve these exercises directly in the embedded code editor.
Generic Functions
Generics let you write code that works with multiple types. Instead of writing separate functions for each type, you write one function that works with any type.
Let’s start with a simple example. Suppose we want to find the larger of two values:
That’s repetitive! Let’s use generics and require a trait PartialOrd to make a single function that works for any type that can be compared:
Let’s break this down:
<T>declares a generic type parameter namedTPartialOrdis a trait that allows comparison using<,>, etc.T: PartialOrdis a trait bound - it saysTmust implement thePartialOrdtrait (which provides comparison)- The function
largerworks with any typeTthat can be compared
Note: Traits are a way to define shared behavior; we’ll look them in more detail later in this chapter. Also, type parameter names are conventionally single capital letters:
T(for Type),U,V, etc. You can use descriptive names likeItemorKeyif it makes your code clearer.
Multiple Type Parameters
Functions can have multiple generic type parameters:
Here, Point<T, U> can have different types for x and y.
Generic Structs and Enums
We’ve been already using generic types. For example, Option<T> and Result<T, E> are generic enums:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
We can create our own generic structs and enums too (this was already shown in the Pair Container exercise above). For example, if we would like to model a container that holds items of any type, we can define a generic struct:
Notice the impl<T> syntax - we need to declare the generic type parameter for the implementation block too.
Generic Methods with Constraints
You can implement methods only for specific types. For example, the type std::fmt::Display allows formatting with {}, and Clone allows creating copies of values.
We can add methods that only work when T implements these traits:
Traits: Defining Shared Behavior
Traits define functionality that types can implement. If you’re familiar with interfaces in other languages, traits are similar - they specify a set of methods that a type must provide.
In the Statistics HashMap exercise, you worked on a generic StatisticsHashMap struct that kept track of cache hits and misses.
The keys were such that they had to implement the Eq, Hash, and Clone traits, while the values had to implement the Clone trait. This ensured that the hashmap could correctly manage key-value pairs and provide accurate statistics.
Defining a Trait
Let’s create a trait for things that can be summarized:
A trait is defined with the trait keyword, followed by the name of the trait, and a body with method signatures. Types implement the trait using impl TraitName for TypeName.
Try what happens above if you remove the summarize method from one of the implementations! Does the program still compile?
In a similar way, we could e.g. have a Priceable trait for items that have a price. This is what it could look like in our coffee shop:
Trait Bounds
We can use traits to constrain generic types. This is called a trait bound:
Multiple Trait Bounds
You can require multiple traits:
Note: The
+syntax combines multiple trait bounds. You can also use thewhereclause for more complex bounds, which we’ll see later.
Static and Dynamic Dispatch
In the materials above, we saw a function that used generics with trait bounds to calculate the total sum of a slice of Priceables:
fn calculate_total<T: Priceable>(items: &[T]) -> f64 {
items.iter().map(|item| item.price()).sum()
}
The function has a limitation though; it only works with slices of the same type (e.g., &[Beverage] or &[FoodItem]), but not with mixed types.
With Rust, there are two ways to handle polymorphism when working with traits: static dispatch with generics and dynamic dispatch with trait objects.
Static Dispatch with Generics
When you use generics with trait bounds, Rust performs monomorphization at compile time. This means the compiler generates a separate copy of the function for each concrete type you use:
Behind the scenes, the compiler essentially creates two functions, one for each type:
calculate_total::<Beverage>calculate_total::<Pastry>
The advantage of this is that there is no runtime overhead when calling the function as the compiler knows exactly which method to call. However, this can lead to larger binary sizes since code is duplicated for each type. Similarly, you can’t have a collection of different types (e.g., Vec<T>).
Dynamic Dispatch with Trait Objects
Sometimes you need to store different types together or don’t know the concrete type at compile time. This is where trait objects come in, using the dyn keyword:
Note the diffference:
- Previously:
fn calculate_total<T: Priceable>(items: &[T]) -> f64— the function only accepted slices of a single typeTthat implementsPriceable. The trait boundT: Priceableenforces this at compile time. - Now:
fn calculate_total(items: &[&dyn Priceable]) -> f64— the function accepts a slice of references to trait objects (&dyn Priceable). This allows the function to accept references to any type that implementsPriceable, enabling polymorphism at runtime.
The above function could as well use a vector, e.g.:
let mut items: Vec<&dyn Priceable> = Vec::new();
items.push(&coffee);
items.push(&croissant);
let total = calculate_total(&items);
Owned Trait Objects with Box
Often you’ll see trait objects wrapped in Box<dyn Trait>. This allows you to store trait objects with ownership:
Use static dispatch when you know the types at compile time and want maximum performance, and do not need to store and process different types together. Use dynamic dispatch when you need flexibility to work with different types at runtime, especially when storing them together in collections. The Box pointer allows ownership of trait objects, enabling dynamic dispatch with heap allocation.
Common Traits
Rust’s standard library provides many useful traits. Let’s explore the most common ones.
Display and Debug
Display is for user-facing output, Debug is for programmer-facing output:
Note:
#[derive(Debug)]automatically implementsDebugfor simple types. ForDisplay, you always need to implement it manually because there’s no single “correct” way to display something to users.
Clone and Copy
Clone creates a deep copy, Copy creates a cheap bitwise copy:
Note: A type can only implement Copy if all its parts implement Copy. Above, Order can’t be Copy because it contains a Vec, which doesn’t implement Copy.
PartialEq, Eq, and PartialOrd
PartialEq allows equality comparisons:
When implemented via derive, the PartialEq trait compares all fields for equality. If you need custom behavior, you can implement it manually:
Eq is a marker trait for types where a == a is always true (some types with floating-point numbers don’t satisfy this):
PartialOrd allows ordering comparisons; it requires PartialEq to be implemented as well. Below, the comparison is implemented based on the level of the coffee strength:
Iterable
You can make your own types iterable by implementing the Iterator trait:
The Iterator trait requires you to define the associated type Item (the type of items produced) and the method next, which returns the next item or None when done.
Let’s create a more practical example - an iterator over drink sizes:
Default Implementations
Traits can provide default implementations for methods:
Default implementations can call other methods in the trait, even if those methods don’t have default implementations. This allows you to define a trait with one required method and several provided methods that build on it.
The Where Clause
For complex trait bounds, the where clause improves readability:
Example: Coffee Shop Plugin System
Let’s build a comprehensive example that demonstrates generics and traits working together:
This example demonstrates:
1. Multiple Traits
trait PaymentMethod { ... }
trait Sellable { ... }
trait Discountable: Sellable { ... }
We define traits for different behaviors.
2. Default Implementations
fn supports_refund(&self) -> bool {
true // Can be overridden
}
Traits can provide default behavior.
3. Trait Bounds on Generics
struct Order<T: Sellable> {
items: Vec<T>,
}
Constraining generic types with traits.
4. Conditional Implementation
impl<T: Sellable + fmt::Display> Order<T> {
fn display(&self) { ... }
}
Methods only available when T implements both traits.
5. Generic Functions
fn calculate_category_totals<T: Sellable>(items: &[T]) -> HashMap<String, f64>
Functions that work with any type implementing a trait.
6. Trait Objects
let mixed_items: Vec<Box<dyn Sellable>> = vec![...]
Storing different types that implement the same trait.
7. Trait Inheritance
trait Discountable: Sellable { ... }
Discountable requires Sellable to be implemented.
Summary
In this chapter, we explored Rust’s powerful generic and trait systems:
- Generic functions let you write code that works with multiple types
- Type parameters are declared with angle brackets:
<T> - Generic structs and enums can store values of any type
- Traits define shared behavior that types can implement
- Trait bounds constrain generic types:
T: Trait - Common traits include
Display,Debug,Clone,Copy,PartialEq, andEq - Default implementations provide trait methods without requiring implementation
- Static dispatch (generics) is fast but increases binary size
- Dynamic dispatch (trait objects with
Box<dyn Trait>) is flexible but slower - The where clause makes complex trait bounds more readable
- Trait inheritance lets you build traits on top of other traits
Generics and traits are a key part in Rust’s zero-cost abstractions (with some trade-offs such as dyn that are fine when you know how to use them). They let you write flexible, reusable code without sacrificing performance.