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.
Trait Bounds
0 / 3 points
What is the purpose of a trait bound like <T: PartialOrd>?
Multiple Type Parameters
Functions can have multiple generic type parameters:
Here, Point<T, U> can have different types for x and y.
Pair Container
0 / 10 points
The handout comes with a partially completed Pair<T, U> struct that stores two values of potentially different types. Add the following methods for the struct:
fn first(&self) -> &T: A method that returns a reference to the first value.fn second(&self) -> &U: A method that returns a reference to the second value.fn swap(self) -> Pair<U, T>: A method that swaps the two values and returns a newPairwith the types reversed.
Here’s example code and expected output:
let pair = Pair::new(5, "hello");println!("First: {}", pair.first()); // prints First: 5println!("Second: {}", pair.second()); // prints Second: hellolet swapped = pair.swap();println!("Swapped First: {}", swapped.first()); // prints Swapped First: helloprintln!("Swapped Second: {}", swapped.second()); // prints Swapped Second: 5Generic 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.
Statistics HashMap
0 / 15 points
The assignment template comes with a generic StatisticsHashMap<K: Eq + Hash + Clone, V: Clone> struct that uses a HashMap to store key-value pairs.
We’ll learn what
Eq,Hash, andClonemean in the lesson material a bit later — for now, just include them as constraints on the generic types and know that you can use types that implement these traits as keys and values in the hashmap.
Implement the following methods for the StatisticsHashMap struct:
fn get(&mut self, key: &K) -> Option<&V>: A method to retrieve a value by key. ReturnsSome(&V)if the key exists, otherwise returnsNone.fn put(&mut self, key: K, value: V): A method to insert a key-value pair into the cache.fn stats(&self) -> (usize, usize): A method that returns a tuple containing the number of cache hits and misses.
Every time the get method is called, it should update the hit/miss statistics accordingly. You can use the contains_key method of HashMap to check if a key exists.
let mut map: StatisticsHashMap<String, i32> = StatisticsHashMap::new();map.put("one".to_string(), 1);map.put("two".to_string(), 2);if let Some(value) = map.get(&"one".to_string()) { println!("Found: {}", value); // prints Found: 1} else { println!("Not found");}let (hits, misses) = map.stats();println!("Hits: {}, Misses: {}", hits, misses); // prints Hits: 1, Misses: 0Generic 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:
Conditional Method Implementation
0 / 3 points
What does this syntax mean?
impl<T: Display> Container<T> { fn show(&self) { ... }}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?
Implementing Traits
0 / 3 points
Which statements about implementing traits are true?
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:
Calculating the Bill
0 / 10 points
The assignment template comes with the above example code. Fill in the generic function fn calculate_bill<T: Priceable>(items: &[T], tip_percent: f64) -> f64 so that it calculates the total bill amount including tip for a list of items with the Priceable trait. Only modify the function, nothing else.
The function should sum the prices of all items and then add the specified tip percentage to the total.
Example usage:
let espresso = Beverage::new("Espresso", 2.00);let latte = Beverage::new("Latte", 3.00);
let items = vec![espresso, latte];
let total_bill = calculate_bill(&items, 0.15);println!("Bill (including 15% tip): {:.2} euros", total_bill);Note! The calculate_bill function that we create here would not work if the items parameter would contain both food items and beverages, even though both implement the Priceable trait. We’ll learn how to solve that problem in a moment!
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.
Multiple Trait Bounds
0 / 3 points
What does the syntax <T: Trait1 + Trait2> mean?
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);Better Bill Calculation
0 / 5 points
Fill in the generic function fn calculate_bill(items: &[&dyn Priceable], tip_percent: f64) -> f64 so that it calculates the total bill amount including tip for a list of items with the Priceable trait. With the dyn keyword, the function should be able to accept a slice of references to any type that implements the Priceable trait, allowing for a mix of different item types in the same collection.
Only modify the function, nothing else.
Example usage:
let espresso = Beverage::new("Espresso", 2.00);let latte = Beverage::new("Latte", 3.00);
let mut items: Vec<&dyn Priceable> = Vec::new();items.push(&espresso);items.push(&latte);let total = calculate_bill(&items, 0.15);println!("Bill (including 15% tip): {:.2} euros", total_bill); // Bill (including 15% tip): 5.75 eurosOwned Trait Objects with Box
Often you’ll see trait objects wrapped in Box<dyn Trait>. This allows you to store trait objects with ownership:
Extending Order System
0 / 15 points
The assignment template comes with the above order system. Implement the two following methods for the Order struct:
fn remove_item(&mut self, index: usize) -> Option<Box<dyn MenuItem>>: removes and returns the item at the given index, orNoneif the index is out of bounds. You can use Vector’s methodremoveto remove an item from a vector.fn items_over(&self, min_price: f64) -> Vec<&str>: returns a vector of names of all items that cost more thanmin_price.
The main function in the starter code already assumes that the methods exist and shows example usage.
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.
Order Formatting
0 / 10 points
The assignment template comes again with the order system. Implement the Display trait for the Order struct so that the order can be easily printed in a user-friendly format.
Use the following format for displaying the order:
A total of 5 item(s): 42.50 EUR.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:
Pyramid Stack
0 / 20 points
The assignment template comes with a struct called PyramidStack that represents a stack of items where each layer of the stack must be smaller than the layer below it. The stack can hold any item that implements the Stackable trait, which requires a method size(&self) -> u32 that returns the size of the item. There are also a handful of traits that have been implemented.
Implement the function push(&mut self, item: Box<dyn Stackable>) -> Result<(), String> for the PyramidStack struct. This function should add the given item to the top of the stack if it is smaller than the item currently at the top of the stack (or if the stack is empty). If the item is too large to be placed on top of the stack, the function should return an Err with an appropriate error message. Similarly, if the size of the item is zero or smaller, the function should also return an Err with an appropriate error message. If the item is successfully added to the stack, the function should return Ok(()).
Hint! Pattern matching is useful for checking whether the stack is empty or not. Also, the provided trait implementations for Box<dyn Stackable> allow comparisons using <.
For example, for the following code:
let mut stack = PyramidStack::new();stack.push(Box::new(Block { size: 10 })).unwrap();stack.push(Box::new(Block { size: 8 })).unwrap();stack.push(Box::new(Block { size: 6 })).unwrap();stack.push(Box::new(Chair::new())).unwrap();stack.display();The output should be as follows:
Stack (top to bottom):Chair (size: 1)Block (size: 6)Block (size: 8)Block (size: 10)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:
Iterator Trait Implementation
0 / 3 points
What must you implement to make a custom type iterable?
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.
Default Trait Implementations
0 / 3 points
What are default trait implementations?
The Where Clause
For complex trait bounds, the where clause improves readability:
Derive Macro Limitations
0 / 3 points
Which traits can be automatically derived using #[derive(...)]?
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.
Coffee Shop Loyalty System
0 / 25 points
The assignment template comes with the above coffee shop example and a LoyaltyTier trait that defines the behavior of different loyalty tiers in a coffee shop. The trait has three methods:
tier_name(&self) -> &str- returns the name of the tierdiscount_rate(&self) -> f64- returns the discount rate (0.05 = 5%)points_per_euro(&self) -> u32- returns points earned per euro spent
Three tier implementations are already provided: BronzeTier (0% discount, 10 points/euro), SilverTier (5% discount, 15 points/euro), and GoldTier (10% discount, 20 points/euro).
Your task is to implement the LoyaltyAccount<T: LoyaltyTier> struct. First, add the fields to the struct to store the customer’s name, current points balance, and their loyalty tier. Then, implement the following methods for the struct:
-
new(name: String, tier: T) -> Self- Creates a new loyalty account with the given name and tier, starting with 0 points. -
add_points(&mut self, amount: f64)- Calculates points based on the amount spent and the tier’s points-per-euro rate, then adds them to the account’s points balance. Round down to the nearest integer. -
apply_discount(&self, amount: f64) -> f64- Returns the discounted price by applying the tier’s discount rate to the given amount. -
redeem_points(&mut self, points: u32) -> Result<f64, String>- Converts points to euros (100 points = €1) and deducts them from the account. ReturnsOk(discount_value)if the account has enough points, orErrwith an appropriate message if not.
The struct already has a method display_info(&self) that shows a summary.
For example, for the following code:
let mut alice = LoyaltyAccount::new(String::from("Alice"), SilverTier);
let purchase_amount = 20.0;let discounted = alice.apply_discount(purchase_amount);println!("Original: {:.2}, After discount: {:.2}", purchase_amount, discounted);
alice.add_points(discounted);alice.display_info();
match alice.redeem_points(100) { Ok(value) => println!("Redeemed for {:.2} euro discount", value), Err(e) => println!("Error: {}", e),}
alice.display_info();The output should be:
Original: 20.00, After discount: 19.00=== Loyalty Account ===Customer: AliceTier: Silver (5% discount)Points: 285
Redeemed for 1.00 euro discount=== Loyalty Account ===Customer: AliceTier: Silver (5% discount)Points: 185Summary
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.