06 · Collections (Vec, String, HashMap)¶
🎥 Video walkthrough¶
So far you've worked with single values and fixed-size arrays. Real programs
need collections that can grow: an ordered, resizable list (Vec<T>), a
growable piece of text (String), and a lookup table of key-value pairs
(HashMap<K, V>). All three live on the heap, which is exactly why they can
grow — the compiler doesn't need to know their size ahead of time.
Vec<T> — a growable list¶
fn main() {
// Two ways to create a Vec:
let mut numbers: Vec<i32> = Vec::new(); // empty, type annotated
numbers.push(10);
numbers.push(20);
numbers.push(30);
let mut fruits = vec!["apple", "banana", "cherry"]; // vec! macro, type inferred
println!("numbers = {:?}", numbers);
println!("fruits = {:?}", fruits);
// Indexing (panics if out of bounds)
println!("first fruit: {}", fruits[0]);
// .get() returns Option<&T> instead of panicking
match fruits.get(10) {
Some(f) => println!("found: {f}"),
None => println!("no fruit at index 10"),
}
// pop() removes and returns the last element, wrapped in Option
let last = numbers.pop();
println!("popped: {:?}", last); // Some(30)
println!("numbers now = {:?}", numbers);
// len() and iterating
println!("fruits has {} items", fruits.len());
for fruit in fruits.iter() {
println!("- {fruit}");
}
// Mutating while iterating with iter_mut()
for fruit in fruits.iter_mut() {
*fruit = if *fruit == "banana" { "BANANA" } else { fruit };
}
println!("fruits = {:?}", fruits);
}
numbers = [10, 20, 30]
fruits = ["apple", "banana", "cherry"]
first fruit: apple
no fruit at index 10
popped: Some(30)
numbers now = [10, 20]
fruits has 3 items
- apple
- banana
- cherry
fruits = ["apple", "BANANA", "cherry"]
fruits.iter() borrows each element as &T so the Vec is still usable
afterward; a plain for fruit in fruits (no .iter()) would move the Vec
and consume it, which only matters once the elements aren't a Copy type
like &str.
String vs &str¶
Rust has two string types, and the difference matters constantly:
&str— a borrowed view into string data (a "string slice"). String literals like"hello"are&str.String— an owned, growable, heap-allocated string.
fn main() {
let borrowed: &str = "hello"; // borrowed, fixed
let owned: String = String::from("hello"); // owned, growable
let also_owned: String = "hello".to_string(); // another way to own it
// Concatenation with + (consumes the left-hand String!)
let greeting = String::from("Hello, ") + "world" + "!";
println!("{greeting}");
// format! is usually nicer -- doesn't consume anything, works like println!
let name = "Ferris";
let message = format!("Hello, {name}! You are {} years old.", 10);
println!("{message}");
// Growing a String in place
let mut sentence = String::from("Rust");
sentence.push_str(" is fun");
sentence.push('!');
println!("{sentence}");
// Iterating over characters
for c in "Ferris".chars() {
print!("{c}-");
}
println!();
// A &str borrowed from a String (slicing)
let slice: &str = &sentence[0..4];
println!("first 4 bytes: {slice}");
let _ = borrowed;
let _ = owned;
let _ = also_owned;
}
A good rule of thumb while learning: use &str for function parameters when
you only need to read text, and String when you need to own or build
text. Slicing a String (&sentence[0..4]) works on byte indices, so be
careful with non-ASCII text — that's a subtlety you'll revisit later, not
something to worry about yet.
HashMap<K, V> — key-value lookup¶
use std::collections::HashMap;
fn main() {
let mut scores: HashMap<String, i32> = HashMap::new();
scores.insert(String::from("Alice"), 90);
scores.insert(String::from("Bob"), 85);
scores.insert(String::from("Alice"), 95); // overwrites the previous value
// .get() returns Option<&V> -- there's no guarantee the key exists
match scores.get("Alice") {
Some(score) => println!("Alice scored {score}"),
None => println!("Alice has no score"),
}
match scores.get("Charlie") {
Some(score) => println!("Charlie scored {score}"),
None => println!("Charlie has no score yet"),
}
// entry().or_insert() -- insert a default only if the key is missing
scores.entry(String::from("Charlie")).or_insert(0);
*scores.entry(String::from("Bob")).or_insert(0) += 10; // bump Bob's score
// Iterating (order is NOT guaranteed with HashMap)
let mut pairs: Vec<(&String, &i32)> = scores.iter().collect();
pairs.sort(); // sort so the output below is deterministic
for (name, score) in pairs {
println!("{name}: {score}");
}
println!("total entries: {}", scores.len());
}
The entry().or_insert() pattern is the idiomatic way to say "give me a
mutable handle to this key's value, inserting a default first if it isn't
there yet" — it's the classic tool for counting things, like tallying how
many times each word appears in a block of text.
How It Actually Works¶
Vec<T> is three machine words: a pointer to a heap buffer, a length, and a
capacity. Pushing past capacity triggers a reallocation — typically doubling
capacity — which is why appending in a tight loop is usually amortized O(1)
per push rather than O(n): most pushes just write into already-allocated
space, and the occasional reallocation-and-copy is spread across all the
pushes that led up to it. Vec::with_capacity(n) skips the repeated
doubling entirely when you know the size up front. HashMap's unordered
iteration isn't an implementation quirk you're stuck with — it's a
deliberate security property: Rust's default hasher (SipHash) is keyed with
a random seed generated per-HashMap at runtime specifically to make hash
flooding attacks (an adversary crafting keys that all collide into the same
bucket) infeasible, and the random seed is also why insertion order can
differ between runs of the identical program.
.entry(key).or_insert(default) avoids Rust's usual "look up, then look up
again to mutate" tax: without it, checking if map.contains_key(&k) and then
calling map.get_mut(&k) would each walk the hash table separately, and the
borrow checker would actually reject holding a mutable reference from one
call across the other. entry() instead does a single lookup and returns an
Entry enum (Occupied or Vacant) representing that one bucket slot, so
or_insert can act on it directly — one hash-table traversal instead of two,
and no borrow conflict because there's only ever one live reference into the
map at a time.
Cheat sheet¶
| Type | Owns its data? | Grows? | Common methods |
|---|---|---|---|
Vec<T> |
Yes | Yes | .push(), .pop(), .get(), .len(), .iter() |
String |
Yes | Yes | .push_str(), .push(), +, format!, .chars() |
&str |
No (borrowed) | No | .len(), .chars(), slicing (&s[0..3]) |
HashMap<K, V> |
Yes | Yes | .insert(), .get(), .entry().or_insert(), .iter() |
🔀 See this in another language¶
Exercise¶
Write a program that takes a hardcoded sentence (a &str), splits it into
words with .split_whitespace(), and uses a HashMap<&str, i32> with the
entry().or_insert() pattern to count how many times each word appears.
Print the results. Then write a second function that takes a Vec<i32> and
returns a new Vec<i32> containing only the even numbers, using a for loop
and .push().