Rust Fundamentals

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 BTreeMap instead (also in std::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 of SalesEntry and returns a HashMap where 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 of SalesEntry and returns a HashMap where 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:

  • String is an owned, growable string on the heap
  • &str is 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 map and filter don’t do anything by themselves - they’re lazy. You need to call collect() 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 as Option<f64>. If there are no readings, it should return None.
  • fn max_temperature(&self) -> Option<i32> - returns the maximum temperature recorded as Option<i32>. If there are no readings, it should return None.
  • fn min_temperature(&self) -> Option<i32> - returns the minimum temperature recorded as Option<i32>. If there are no readings, it should return None.
  • 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: 2
println!(
"Below or equal to 20: {}",
tracker.count_below_or_equal(20)
); // Below or equal to 20: 1

Example: 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 descending

Converting 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 size
  • fn 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 sales
Most Popular Sizes:
Medium: 3 sales
Large: 2 sales
Small: 1 sales

Summary

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 returns Option instead 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 map and filter transform iterators
  • Iterator consumers like collect, sum, and fold produce 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.