Rust Fundamentals

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 dyn to 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 named T
  • PartialOrd is a trait that allows comparison using <, >, etc.
  • T: PartialOrd is a trait bound - it says T must implement the PartialOrd trait (which provides comparison)
  • The function larger works with any type T that 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 like Item or Key if 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 new Pair with the types reversed.

Here’s example code and expected output:

let pair = Pair::new(5, "hello");
println!("First: {}", pair.first()); // prints First: 5
println!("Second: {}", pair.second()); // prints Second: hello
let swapped = pair.swap();
println!("Swapped First: {}", swapped.first()); // prints Swapped First: hello
println!("Swapped Second: {}", swapped.second()); // prints Swapped Second: 5

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.

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, and Clone mean 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. Returns Some(&V) if the key exists, otherwise returns None.
  • 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: 0

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:


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.

Traits and Statistics HashMap

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 the where clause 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 type T that implements Priceable. The trait bound T: Priceable enforces 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 implements Priceable, 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 euros

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:


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, or None if the index is out of bounds. You can use Vector’s method remove to 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 than min_price.

The main function in the starter code already assumes that the methods exist and shows example usage.


Static vs Dynamic Dispatch

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 implements Debug for simple types. For Display, 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 tier
  • discount_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:

  1. new(name: String, tier: T) -> Self - Creates a new loyalty account with the given name and tier, starting with 0 points.

  2. 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.

  3. apply_discount(&self, amount: f64) -> f64 - Returns the discounted price by applying the tier’s discount rate to the given amount.

  4. redeem_points(&mut self, points: u32) -> Result<f64, String> - Converts points to euros (100 points = €1) and deducts them from the account. Returns Ok(discount_value) if the account has enough points, or Err with 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: Alice
Tier: Silver (5% discount)
Points: 285
Redeemed for 1.00 euro discount
=== Loyalty Account ===
Customer: Alice
Tier: Silver (5% discount)
Points: 185

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, and Eq
  • 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.