Collections and Iteration
Learning Objectives
- You can use vectors to store variable-length collections of data.
- You understand how to work with HashMaps for key-value storage.
- You know the nuances of String manipulation and why indexing is tricky.
- You can use iterators to process collections efficiently.
- You understand common iterator methods like map, filter, and collect.
- You know how to use peekable iterators for lookahead scenarios.
Warming up
Here is a set of tiny programming tasks to get you familiar with syntax that relates to collections and iteration. You can solve these exercises directly in the embedded code editor.
Vectors
We’ve seen arrays in previous chapters, but they have a fixed size. We’ve also implemented our own linked list while learning about Box for recursive types, but that’s not very efficient for many use cases.
Rust also comes with Vectors (Vec<T>), which are growable arrays that can expand and shrink as needed. There is also a macro vec! for easy vector creation.
Creating Vectors
There are several ways to create a vector:
Note: Vectors store their data on the heap, not the stack. This allows them to grow dynamically. The vector itself (which contains a pointer, length, and capacity) lives on the stack, but the actual data lives on the heap.
Vector Storage Location
0 / 3 points
Where are vectors stored in memory?
Adding and Removing Elements
Accessing Elements
There are two ways to access vector elements: indexing and the get method.
Note: Use
get()when the index might be invalid. Use direct indexing ([]) only when you’re certain the index is valid. Panicking in production code is usually a bad idea!
Vector Access
0 / 3 points
What is the difference between using vec[index] and vec.get(index) to access vector elements?
Iterating Over Vectors
Vector Iteration Modes
0 / 3 points
What is the difference between these three iteration methods?
for item in vec.iter() { }for item in vec.iter_mut() { }for item in vec.into_iter() { }Vectors and Ownership
Ownership rules apply to vectors too:
Max Value from Vector
0 / 10 points
Fill in the function fn max(numbers: &Vec<i32>) -> Result<i32, Error> that takes a reference to a vector of integers and returns the maximum value in the vector. If the vector is empty, return an Err(Error::EmptyVector) (use vector’s is_empty method to check whether it is empty).
Storing Different Types
Vectors must store elements of the same type. But sometimes you need flexibility:
enum MenuItem { Drink { name: String, price: f64 }, Food { name: String, price: f64, calories: u32 }, Dessert { name: String, price: f64, sugar_free: bool },}
fn main() { let menu = vec![ MenuItem::Drink { name: String::from("Espresso"), price: 2.50, }, MenuItem::Food { name: String::from("Croissant"), price: 3.00, calories: 350, }, MenuItem::Dessert { name: String::from("Brownie"), price: 4.50, sugar_free: false, }, ];
for item in &menu { match item { MenuItem::Drink { name, price } => { println!("{}: {:.2} euros (drink)", name, price); } MenuItem::Food { name, price, calories } => { println!("{}: {:.2} euros ({} cal)", name, price, calories); } MenuItem::Dessert { name, price, sugar_free } => { let sf = if *sugar_free { " (sugar-free)" } else { "" }; println!("{}: {:.2} euros{}", name, price, sf); } } }}HashMaps
While vectors use numeric indices, HashMaps store key-value pairs where you can use any type as the key (as long as it implements the Eq and Hash traits).
Creating HashMaps
Unlike vectors, HashMaps aren’t automatically imported. You need to bring them into scope:
Accessing Values
Updating Values
HashMap Entry API
0 / 3 points
What does the entry API pattern map.entry(key).or_insert(value) do?
The Entry API
The entry API is powerful for updating values based on whether they exist:
HashMap Entry API Usage
0 / 3 points
Given this code, what happens when we encounter the same key multiple times?
let mut counts = HashMap::new();for item in items { *counts.entry(item).or_insert(0) += 1;}Iterating Over HashMaps
Note: HashMaps don’t maintain any particular order. If you need ordering, consider using
BTreeMapinstead (also instd::collections).
Sales Analysis
0 / 15 points
Fill in the two functions:
fn sales_counts_per_item(sales: &Vec<SalesEntry>) -> HashMap<String, i32>that takes a reference to a vector ofSalesEntryand returns aHashMapwhere the keys are item names and the values are the total quantities sold for each item.fn total_sales_per_item(sales: &Vec<SalesEntry>) -> HashMap<String, f64>that takes a reference to a vector ofSalesEntryand returns aHashMapwhere the keys are item names and the values are the total sales (price * quantity) for each item.
If the input vector is empty, both functions should return an empty HashMap.
Strings Revisited
We’ve used strings throughout this course, but let’s dive deeper. Rust has a complex relationship with strings because it prioritizes correctness and performance.
String vs &str
As we learned earlier:
Stringis an owned, growable string on the heap&stris a borrowed string slice
Building Strings
Why You Can’t Index Strings
In many languages, you can access string characters by index. Not in Rust:
let drink = String::from("Latte");let first = drink[0]; // Error!Why? Because Rust strings are UTF-8 encoded. A character might be 1, 2, 3, or 4 bytes. Indexing by byte position could split a character in half!
String Length vs Character Count
0 / 3 points
What will this code print?
let text = "Hi! 😀";println!("{}", text.len());println!("{}", text.chars().count());Iterating Over Strings
Since indexing doesn’t work, use iteration:
Common String Methods
Word Count Analysis
0 / 10 points
Fill in the function word_count_analysis that takes a string slice text and a target word target, and returns the number of times the target word appears in the text, regardless of case. The function should split the text into words, compare each word to the target word in a case-insensitive manner, and count the occurrences.
Use space (” ”) as the delimiter to split the text into words, and use the to_lowercase() method to transform the words to lowercase for comparison. You can compare two strings for equality using the == operator.
Iterators
Iterators are a powerful feature in Rust. We’ve been using them with for loops, but there’s much more to them.
What is an Iterator?
An iterator is something that lets you process a sequence of elements. In Rust, iterators are lazy - they don’t do anything until you consume them.
Iterator Laziness
0 / 3 points
What does it mean that iterators are “lazy” in Rust?
Three Ways to Get Iterators
Iterator Methods
Iterators have many powerful methods. These are called iterator adapters because they produce new iterators:
Note: Notice that
mapandfilterdon’t do anything by themselves - they’re lazy. You need to callcollect()or another consuming method to actually process the iterator.
Common Iterator Consumers
These methods consume the iterator and produce a final result:
Iterator Adapters vs Consumers
0 / 3 points
Which of the following are iterator adapters (return a new iterator) versus consumers (produce a final value)?
The fold Method
fold is a powerful method that lets you accumulate a value:
The fold Method
0 / 3 points
What does the fold method do?
vec.iter().fold(initial, |accumulator, item| { ... })Chaining Multiple Operations
The real power comes from chaining multiple iterator methods:
Temperature Tracker
0 / 30 points
Create a struct TemperatureTracker that contains a vector to store daily temperature readings (as i32 values). Add a constructor function to initialize an empty tracker.
Implement the following methods for the struct:
fn add_temperature(&mut self, temperature: i32)- adds a new temperature reading to the tracker.fn average(&self) -> Option<f64>- calculates and returns the average temperature of all recorded readings asOption<f64>. If there are no readings, it should returnNone.fn max_temperature(&self) -> Option<i32>- returns the maximum temperature recorded asOption<i32>. If there are no readings, it should returnNone.fn min_temperature(&self) -> Option<i32>- returns the minimum temperature recorded asOption<i32>. If there are no readings, it should returnNone.fn count_above_or_equal(&self, threshold: i32) -> i32- counts how many days had temperatures above or equal to the given threshold.fn count_below_or_equal(&self, threshold: i32) -> i32- counts how many days had temperatures below or equal to the given threshold.
Here’s an example of how the struct and its methods could be used:
let mut tracker = TemperatureTracker::new();tracker.add_temperature(23);tracker.add_temperature(18);tracker.add_temperature(30);
println!("Average: {:.2?}", tracker.average()); // Average: Some(23.67)println!("Max: {:?}", tracker.max_temperature()); // Max: Some(30)println!("Min: {:?}", tracker.min_temperature()); // Min: Some(18)println!( "Above or equal to 20: {}", tracker.count_above_or_equal(20)); // Above or equal to 20: 2println!( "Below or equal to 20: {}", tracker.count_below_or_equal(20)); // Below or equal to 20: 1Example: Coffee Shop Analytics
The following shows an analytics system that demonstrates the collection and iteration concepts:
The example demonstrates:
1. Vector Usage
struct SalesAnalytics { sales: Vec<Sale>, // Stores all sales}We use a vector to store all sales records.
2. HashMap for Aggregation
fn sales_by_drink(&self) -> HashMap<String, i32> { let mut counts = HashMap::new(); for sale in &self.sales { *counts.entry(sale.drink.clone()).or_insert(0) += 1; } counts}HashMaps aggregate sales by drink name.
3. Iterator Chains
let large_latte_revenue: f64 = analytics.sales.iter() .filter(|sale| sale.drink == "Latte" && sale.size == "Large") .map(|sale| sale.price) .sum();Multiple iterator operations chained together for complex queries.
4. String Manipulation
Sale { drink: String::from(drink), // Convert &str to String ...}Working with owned strings for data storage.
5. Collection Methods
fn most_popular_drink(&self) -> Option<(String, i32)> { self.sales_by_drink() .into_iter() .max_by_key(|(_, count)| *count)}Using methods like max_by_key to find maximum values.
6. Sorting Collections
let mut drinks: Vec<_> = sales_by_drink.iter().collect();drinks.sort_by_key(|(_, count)| -*count); // Sort descendingConverting HashMaps to vectors for sorting.
Extending Coffee Analytics
0 / 25 points
The assignment template comes with the above coffee shop analytics program. First, modify the program so that the drinks and sizes are represented as enums instead of strings. Use the following definitions:
#[derive(Hash, Eq, PartialEq, Clone, Debug)]enum Drink { Americano, Cappuccino, Espresso, Latte,}
#[derive(Hash, Eq, PartialEq, Clone, Debug)]enum Size { Small, Medium, Large,}The meaning of "[derive(...)] will be explained in the next chapter; for now, just include them as shown.
Then, modify the SalesAnalytics struct methods to use these enums. In particular, the signatures of sales_by_drink and most_popular_drink should be updated to return and accept Drink enum variants instead of strings:
fn sales_by_drink(&self) -> HashMap<Drink, i32>fn most_popular_drink(&self) -> Option<(Drink, i32)>Finally, add two new methods:
fn sales_by_size(&self) -> HashMap<Size, i32>returns a count of sales for each sizefn most_popular_sizes(&self) -> Vec<(Size, i32)>returns a vector of sizes along with their sales count, sorted in descending order.
Example:
let mut analytics = SalesAnalytics::new();
analytics.add_sale(Sale::new(Drink::Americano, Size::Medium, 3.5));analytics.add_sale(Sale::new(Drink::Latte, Size::Large, 4.0));analytics.add_sale(Sale::new(Drink::Latte, Size::Medium, 3.0));analytics.add_sale(Sale::new(Drink::Espresso, Size::Small, 2.5));analytics.add_sale(Sale::new(Drink::Americano, Size::Large, 4.0));analytics.add_sale(Sale::new(Drink::Americano, Size::Medium, 3.5));
println!("Total Revenue: {}€", analytics.total_revenue());println!("Average Sale: {:.2}€", analytics.average_sale());
if let Some((drink, count)) = analytics.most_popular_drink() { println!("Most Popular Drink: {:?} with {} sales", drink, count);} else { println!("No sales data available.");}
let popular_sizes = analytics.most_popular_sizes();println!("Most Popular Sizes:");for (size, count) in popular_sizes { println!("{:?}: {} sales", size, count);}Outputs:
Total Revenue: 20.50€Average Sale: 3.42€Most Popular Drink: Americano with 3 salesMost Popular Sizes:Medium: 3 salesLarge: 2 salesSmall: 1 salesSummary
In this chapter, we explored Rust’s powerful collection types and iteration capabilities:
- Vectors (
Vec<T>) are growable arrays stored on the heap - Use
get()for safe indexing that returnsOptioninstead of panicking - HashMaps store key-value pairs and use the entry API for efficient updates
- Strings are complex because they’re UTF-8 encoded - you can’t index them directly
- Iterate over strings with
.chars()for characters or.bytes()for bytes - Iterators are lazy and don’t do work until consumed
- Iterator adapters like
mapandfiltertransform iterators - Iterator consumers like
collect,sum, andfoldproduce final results - Peekable iterators let you look ahead at the next element without consuming it
- Use
.peekable()for parsing, grouping consecutive elements, or comparing neighbors - Chain iterator methods for powerful data processing pipelines
Collections and iterators are fundamental to writing efficient, expressive Rust code. The iterator approach often leads to code that’s both more readable and more performant than traditional loops.