Rust Basics #7 Collections and Iterators: Vec, String, HashMap, and Chains Instead of for

4 min read

In Part 2 we said arrays have fixed length and promised that the real workhorses come later. This is that part: the growable list Vec, the string String, the key-value store HashMap, and the Rust way of working with them — iterators.

Vec: the everyday list #

src/main.rs
let mut scores: Vec<u32> = Vec::new();
scores.push(90);
scores.push(85);

let scores = vec![90, 85, 72]; // literal macro

Vec<T> is a growable array with its data on the heap. Because it owns heap data, the ownership rules of Part 3 apply as-is, and the “no push during iteration” compile error from Part 4 had Vec as its stage. There are two ways to access an element, and the difference matters.

src/main.rs
let third = scores[2];        // out of range → panic
let third = scores.get(2);    // out of range → None (Option<&u32>)

Where the index is certainly valid by construction, use [2]; where the index comes from input or a computation, take it with get and handle the Option. The boundary drawn in Parts 5 and 6 — “impossible states panic, expected failures go into the type” — carries straight into the choice of access method.

String: why indexing is not allowed #

Rust’s String is a Vec of UTF-8 bytes. That is why code that feels natural in other languages does not compile.

src/main.rs
let title = String::from("Rust Basics");
let first = title[0]; // compile error: String cannot be indexed

Part 2 said char is a 4-byte Unicode scalar, and Part 4 warned that slicing through the middle of a character panics. The answers converge here. In UTF-8, ASCII characters are one byte but accented letters are two, CJK characters three, emoji four — so title[0] has no guarantee of meaning “the first character.” One byte is not a character. Rather than returning “roughly the first byte,” Rust bans the operation and makes you say what you mean.

src/main.rs
let word = String::from("naïve");
let first_char = word.chars().next();   // Some('n') — character view
let byte_len = word.len();              // 6 — length in bytes
let char_count = word.chars().count();  // 5 — number of characters

That len() returns bytes rather than characters is a classic trap in input-length validation, worth committing to memory. For building strings, push_str and the format! macro are the basic tools.

HashMap: key-value and the entry API #

src/main.rs
use std::collections::HashMap;

let mut stock: HashMap<String, u32> = HashMap::new();
stock.insert(String::from("keyboard"), 12);

let count = stock.get("keyboard"); // Option<&u32>

That lookups return Option should be no surprise by now: a missing key is not null but None. The idiom worth learning is the entry API, which handles “insert if absent, update if present” in a single lookup.

src/main.rs
let mut word_count: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
    *word_count.entry(word).or_insert(0) += 1;
}

The three-step dance — “check if the key exists → insert if not → look up again and modify” — becomes one line. It is the standard form of aggregation code like word counting.

Iterators: the chain that replaces for #

With the three collections in hand, here is the Rust way of processing them. “Sum of scores of 80 or above,” written twice:

src/main.rs
// the for way
let mut total = 0;
for s in &scores {
    if *s >= 80 {
        total += s;
    }
}

// the iterator chain way
let total: u32 = scores.iter().filter(|s| **s >= 80).sum();

The |s| ... passed to filter is a closure — an anonymous function. The chain style is the idiom not by taste but by structure: the mut variable disappears, and “filter, then sum” is expressed by names rather than by the order of operations. Use map for transformation and collect to gather results into a new collection.

src/main.rs
let names: Vec<String> = users.iter()
    .filter(|u| u.active)
    .map(|u| u.name.clone())
    .collect();

Two more facts complete the picture. First, iterators are lazily evaluated: map and filter only stack up a plan, which runs in one pass at a consuming call like collect or sum, so no intermediate collections are built. Compiled performance is on par with a handwritten loop — this is not a trade of speed for expressiveness. Second, iteration also comes in ownership flavors: iter() borrows and iterates by reference, while into_iter() takes ownership as it goes. If you will keep using the collection afterwards, iter() is the default.

Summary #

  • Vec is the everyday list. Structurally certain indexes use [i]; indexes from the outside world go through get and its Option.
  • String is a Vec of UTF-8 bytes, so indexing is banned. Characters go through chars(), and len() counting bytes is the practical trap.
  • HashMap lookups return Option, and aggregation uses the entry(...).or_insert(...) idiom.
  • The default form of collection processing is the iter().filter().map().collect() chain — lazily evaluated, compiling to the same speed as a handwritten loop.
  • Next part: traits and generics. The IOUs accumulated so far — derive(Debug), the Copy trait, Box<dyn Error> — all get settled at once.
X