Learn Rust Series (#7) - Collections
Learn Rust Series (#7) - Collections
What will I learn
- You will learn how
Vecgives you a growable array, and why capacity and length are two different numbers that matter for performance; - how Rust's ownership rules stop iterator invalidation -- a classic C and C++ bug -- dead in its tracks, at compile time;
- how
HashMapstores key-value pairs, and why the entry API is the idiomatic way to update a value in place; - why a
Stringcannot be indexed withs[0], what UTF-8 has to do with it, and how to walk text correctly; - how slices let you borrow a read-only window into a collection without copying a single byte.
Requirements
- A working Rust toolchain (via rustup) from episode 1;
- A terminal and an editor (VS Code with rust-analyzer is a fine pick);
- Episodes 1 to 6 read, because today leans hard on ownership, borrowing,
Option, andResult.
Difficulty
- Beginner
Curriculum (of the Learn Rust Series):
- Learn Rust Series (#1) - Introduction to Rust
- Learn Rust Series (#2) - Variables, Types, Functions
- Learn Rust Series (#3) - Ownership & Borrowing
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn Rust Series (#5) - Structs & Enums
- Learn Rust Series (#6) - Error Handling
- Learn Rust Series (#7) - Collections (this post)
Learn Rust Series (#7) - Collections
Welcome back! Up to now we have mostly been juggling single values -- one number, one struct, one Result. That is fine for teaching, but no real program lives like that. Real programs deal in many things at once: a list of scores, a table of users keyed by name, a chunk of text you need to slice apart. Today we meet the three workhorse collections you will reach for every single day in Rust: the growable array Vec, the key-value HashMap, and the growable text buffer String. And because this is Rust, the ownership rules we fought with back in episode 3 come along for the ride -- except now, instead of getting in your way, they quietly prevent an entire category of bugs that haunt C and C++ programmers to this day.
If you come from Python, you already have the mental models. Vec is Python's list. HashMap is Python's dict. String is, well, Python's str -- sort of, and the "sort of" is where things get interesting, because Rust is honest about text being UTF-8 bytes in a way Python hides from you. Keep those Python parallels in the back of your mind as we go, the same way we have done all series -- it makes the new machinery click a lot faster.
Nota bene: everything today lives on the heap, the region of memory for data whose size can grow and shrink at runtime. A Vec with three elements can become a Vec with three million without you changing a line. That flexibility is exactly why ownership matters here -- somebody has to own that heap memory and free it when it is done, and in Rust that somebody is the collection itself.
Solutions to Episode 6's exercises
As always, let us clear last episode's homework before we open the new box. All three were about Result, ?, and building your own error type -- the perfect on-ramp into collections, which are full of methods returning Option and Result.
Exercise 1 wanted parse_age, doubling a parsed number using ? rather than a match:
fn parse_age(text: &str) -> Result<u32, std::num::ParseIntError> {
let n: u32 = text.parse()?; // Ok -> n; Err -> early return the ParseIntError
Ok(n * 2)
}
fn main() {
println!("{:?}", parse_age("20")); // Ok(40)
println!("{:?}", parse_age("twenty")); // Err(ParseIntError { .. })
}
The whole point was to feel how little code ? costs you. One character does the work of a four-line match, and the failure path is still completely explicit -- you can see the ? marking the spot where the function might bail. Note the let n: u32 annotation: parse is generic over what it parses into, so we tell it the target type on the binding, and Rust threads that back into text.parse() for us.
Exercise 2 was safe_divide, guarding against a division-by-zero panic, then chaining two of them with ?:
fn safe_divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err(String::from("division by zero"));
}
Ok(a / b)
}
fn sum_two_divisions() -> Result<i32, String> {
let x = safe_divide(20, 4)?; // 5
let y = safe_divide(30, 3)?; // 10
Ok(x + y)
}
fn main() {
println!("{:?}", safe_divide(10, 0)); // Err("division by zero")
println!("{:?}", sum_two_divisions()); // Ok(15)
}
safe_divide turns an operation that would crash the program (integer division by zero panics in Rust) into an honest Result the caller can recover from. And sum_two_divisions shows the propagation you get for free: if either divide had returned Err, the ? would have short-circuited and handed that same error straight back, no if ladder in sight.
Exercise 3 was the full custom-error recipe -- parse_point with its own ParsePointError enum:
use std::fmt;
#[derive(Debug)]
enum ParsePointError {
Missing,
BadNumber,
}
impl fmt::Display for ParsePointError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ParsePointError::Missing => write!(f, "expected two comma-separated numbers"),
ParsePointError::BadNumber => write!(f, "a part was not a valid integer"),
}
}
}
impl std::error::Error for ParsePointError {}
fn parse_point(text: &str) -> Result<(i32, i32), ParsePointError> {
let parts: Vec<&str> = text.split(',').collect();
if parts.len() != 2 {
return Err(ParsePointError::Missing);
}
let x = parts[0].trim().parse::<i32>().map_err(|_| ParsePointError::BadNumber)?;
let y = parts[1].trim().parse::<i32>().map_err(|_| ParsePointError::BadNumber)?;
Ok((x, y))
}
fn main() {
println!("{:?}", parse_point("3,4")); // Ok((3, 4))
println!("{:?}", parse_point("3")); // Err(Missing)
println!("{:?}", parse_point("3,x")); // Err(BadNumber)
}
Two things worth calling out. First, look -- we already used a Vec here, back in episode 6, without making a fuss about it: text.split(',').collect() gathers the pieces into a Vec<&str> so we can count them and index them. That is your sneak preview of today's star. Second, map_err(|_| ParsePointError::BadNumber) converts the low-level ParseIntError into our error type before the ? propagates it. Last episode we did that conversion with a From impl; here we do it inline with map_err. Both are valid -- From when you want it automatic everywhere, map_err when you want a one-off. Right, enough revision. Let us fill some collections!
Vec: the growable array
A Vec<T> (pronounce it "vector", though it has nothing to do with the maths kind) is a contiguous, growable list of values all of the same type T. You create one, push things onto the end, index into it, iterate over it. If you have written a Python list, you already know the shape:
fn main() {
let mut scores: Vec<i32> = Vec::new();
scores.push(10);
scores.push(25);
scores.push(7);
println!("{:?}", scores); // [10, 25, 7]
println!("first is {}", scores[0]); // first is 10
println!("we have {} scores", scores.len()); // we have 3 scores
let primes = vec![2, 3, 5, 7, 11]; // the vec! macro builds one in place
let sum: i32 = primes.iter().sum();
println!("sum of primes {sum}"); // sum of primes 28
}
Two ways to build one. Vec::new() gives you an empty vector you grow by hand with push (note the mut -- growing is mutation). The vec![...] macro builds a populated one in a single line, which is what you will use most of the time. Indexing with scores[0] reads an element, and .len() tells you how many there are. The {:?} you have seen before -- it is the debug format, and Vec prints itself as a tidy [10, 25, 7].
Capacity is not length
Here is the first thing that trips people up, and it is worth understanding because it explains why Vec is fast. A Vec tracks two numbers: its length (how many elements it currently holds) and its capacity (how many it could hold before it needs to ask the operating system for a bigger chunk of memory). When you push past the capacity, the Vec allocates a larger buffer (typically double), copies the old elements over, and frees the old buffer. That copy is not free, so growing a huge vector one push at a time does quit some behind-the-scenes reallocating.
fn main() {
let mut v: Vec<i32> = Vec::with_capacity(4);
println!("len {}, cap {}", v.len(), v.capacity()); // len 0, cap 4
for i in 0..5 {
v.push(i);
}
println!("len {}, cap {}", v.len(), v.capacity()); // len 5, cap 8 (it grew)
}
Vec::with_capacity(4) pre-books room for four elements while holding zero -- length 0, capacity 4. We push five, and on the fifth push the Vec runs out of room and grows the buffer (on my toolchain it doubled to 8; the exact number is an implementation detail, so do not hard-code it). The lesson: when you know roughly how many elements you will end up with, with_capacity lets you allocate once up front instead of reallocating as you grow. It is a small habit that matters in hot loops.
Indexing safely with get
scores[10] on a three-element vector does not return garbage the way a raw C array would -- it panics, immediately and loudly, because reading out of bounds is a bug and Rust refuses to let it silently corrupt anything. But sometimes an out-of-range index is not a bug, it is just a question you do not know the answer to yet. For that, use .get(), which returns an Option:
fn main() {
let names = vec!["alice", "bob", "carol"];
match names.get(1) {
Some(name) => println!("index 1 is {name}"), // index 1 is bob
None => println!("nothing there"),
}
// names[10] would PANIC; names.get(10) hands back None instead
println!("{:?}", names.get(10)); // None
}
This is the Option from episode 6 doing exactly its job: absence modelled as a value. get(1) gives Some(&"bob"), get(10) gives None, and the type system forces you to handle the "not there" case before you can touch the element. Use [] when an out-of-bounds access would mean a bug you want to crash on; use .get() when it is a legitimate maybe.
Iterating, and who owns what
There are three ways to loop over a Vec, and the difference between them is pure ownership -- the exact same borrowing rules from episode 3, now applied to a collection:
fn main() {
let mut nums = vec![1, 2, 3, 4];
let total: i32 = nums.iter().sum(); // .iter() borrows each element (&T)
println!("total {total}"); // total 10
for n in nums.iter_mut() { // .iter_mut() borrows mutably (&mut T)
*n *= 10;
}
println!("{:?}", nums); // [10, 20, 30, 40]
for n in nums { // plain `nums` MOVES the vector, yielding owned T
print!("{n} "); // 10 20 30 40
}
println!();
// `nums` is gone now -- it was consumed by that last loop
}
Three loops, three levels of access. .iter() hands you shared references (&i32) so you can read without touching -- that is why sum can add them up while leaving the vector intact. .iter_mut() hands you mutable references (&mut i32), so *n *= 10 writes back into each slot in place (the * dereferences to reach the value behind the reference). And looping over the bare nums moves the whole vector into the loop, handing you owned values and leaving nums unusable afterward -- try to print it on the next line and the compiler stops you, because you no longer own it. Same ownership story as always, just wearing a collection's clothes.
The bug Rust makes impossible
Now for my favourite part, and the reason ownership on collections earns its keep. In C++ there is a notorious trap called iterator invalidation: you loop over a container while modifying it, the modification reallocates the underlying buffer, and your loop's pointer is left dangling into freed memory. It compiles, it runs, and it corrupts your program in ways that surface a mile away from the cause. Rust simply does not let you write it:
fn main() {
let mut nums = vec![1, 2, 3];
for n in &nums { // this loop holds a shared borrow of `nums`...
nums.push(*n); // ERROR: cannot borrow `nums` as mutable
}
}
That does not compile, full stop. The for n in &nums loop holds a shared borrow of the vector for its whole duration, and nums.push(...) needs a mutable borrow -- and episode 3's iron rule is that you cannot have a mutable borrow while a shared one is alive. The C++ bug that would corrupt memory at runtime is, in Rust, a compile error you fix in ten seconds before you ever ship it. Having said that, if you genuinely want to grow a list based on its current contents, you just collect the new items separately and extend afterward -- the borrow checker is not stopping you from doing the thing, it is stopping you from doing it in a way that is secretly broken.
HashMap: keys to values
When you need to look things up by a key instead of by a numeric position, you want a HashMap<K, V> -- Python's dict, Java's HashMap, a lookup table. Keys of type K, values of type V, and near-instant lookup regardless of how many entries you have. One wrinkle: unlike Vec, it is not in the automatic prelude, so you use it first:
use std::collections::HashMap;
fn main() {
let mut ages: HashMap<String, u32> = HashMap::new();
ages.insert(String::from("alice"), 30);
ages.insert(String::from("bob"), 25);
match ages.get("alice") {
Some(age) => println!("alice is {age}"), // alice is 30
None => println!("unknown person"),
}
for (name, age) in &ages {
println!("{name} -> {age}"); // order is NOT guaranteed
}
}
insert adds or overwrites a key. get returns an Option<&V> -- Some(&30) for a key that exists, None for one that does not -- the same honest "it might not be there" you saw with Vec::get. Iterating with &ages borrows each key-value pair as a tuple you can destructure right in the for. One thing to internalise: a HashMap has no guaranteed order. Print it twice and the entries may come out differently. If you need sorted or insertion order, that is a job for a different collection (BTreeMap sorts by key) -- reach for those when you actually need the ordering.
The entry API: update-or-insert done right
Here is the single most idiomatic HashMap pattern in all of Rust, and the reason I wanted you comfortable with Option first. Very often you want to say "look up this key; if it is there update it, if it is not insert a starting value". The naive way needs two lookups and some awkward branching. The entry API does it in one clean line:
use std::collections::HashMap;
fn main() {
let text = "the cat sat on the mat the cat";
let mut counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
*counts.entry(word).or_insert(0) += 1;
}
println!("{:?}", counts.get("the")); // Some(3)
println!("{:?}", counts.get("cat")); // Some(2)
println!("{:?}", counts.get("dog")); // None
}
Read *counts.entry(word).or_insert(0) += 1 slowly, because it is dense but beautiful. entry(word) looks the key up and hands back a little handle representing "the slot for this key, occupied or not". .or_insert(0) says "if the slot is empty, put a 0 in it first", and returns a mutable reference to whatever value now lives there. Then *... += 1 dereferences that reference and bumps the count. One expression, one lookup, does the whole update-or-insert dance. This exact word-frequency counter is the textbook example, and once it clicks you will use the entry API constantly. (This is also our first brush with a closure-free but iterator-heavy style -- iterators like split_whitespace get a whole episode of their own a little later, so just enjoy the ride for now.)
String: text is UTF-8, and Rust is honest about it
A String is a growable, heap-allocated, owned text buffer -- essentially a Vec<u8> that is guaranteed to hold valid UTF-8. You build and grow it much like a Vec:
fn main() {
let mut s = String::from("hello");
s.push(' '); // push a single char
s.push_str("world"); // push a whole &str
println!("{s}"); // hello world
println!("byte length {}", s.len()); // byte length 11
let hi = "héllo";
println!("byte length {}", hi.len()); // 6, not 5
println!("char count {}", hi.chars().count()); // 5
}
push adds one character, push_str appends a string slice. All normal so far -- until that last pair of lines. Look carefully: "héllo" has five characters you can see, but .len() reports 6 bytes. That is not a bug, it is UTF-8 being honest. The accented é does not fit in a single byte the way plain ASCII letters do -- it takes two. So "the length of a string" is genuinely ambiguous: do you mean bytes, or characters? Rust makes you say which, because pretending they are the same is how you get corrupted text the moment someone types a non-English name.
Why you cannot index a String
This is the classic Rust head-scratcher for newcomers, and now you have the context to see why:
fn main() {
let s = String::from("hello");
let first = s[0]; // ERROR: `String` cannot be indexed by `usize`
println!("{first}");
}
That does not compile, and it is a deliberate design choice, not an oversight. If s[0] returned a byte, it would give you half a character for any multi-byte letter (garbage, silently). If it returned a character, the lookup could not be the constant-time O(1) that [] promises everywhere else, because finding the Nth character in UTF-8 means walking the bytes from the start. Rather than hand you a footgun or lie about performance, Rust just forbids the indexing and makes you pick an explicit method:
fn main() {
let s = String::from("héllo");
if let Some(c) = s.chars().nth(1) {
println!("second char is {c}"); // second char is é
}
for (i, c) in s.char_indices() {
println!("byte {i}: {c}"); // byte 0: h, byte 1: é, byte 3: l, ...
}
}
chars() gives you an iterator of proper Unicode characters, and .nth(1) fetches the second one (returning an Option, because the string might be too short). char_indices() pairs each character with its byte offset -- notice the jump from byte 1 to byte 3, because é occupied bytes 1 and 2. This is the honest, correct way to walk text, and once you stop expecting s[0] to work you will not miss it. Python papers over all of this; Rust makes it visible, which feels like more work right up until the day it saves you from a mojibake disaster.
Slices: borrowing a window
Last big idea for today, and it ties the whole episode together. A slice is a borrowed view into a contiguous run of a collection -- a Vec, an array, or a String -- without copying the data. You have quietly seen one already: &str (a string slice) is a slice into a String's bytes. The general form uses range syntax:
fn main() {
let nums = vec![10, 20, 30, 40, 50];
let middle = &nums[1..4]; // slice of elements 1, 2, 3 -- borrows, does not copy
println!("{:?}", middle); // [20, 30, 40]
println!("slice sum {}", middle.iter().sum::<i32>()); // slice sum 90
let greeting = String::from("hello world");
let hello = &greeting[0..5]; // a &str slice into the String
println!("{hello}"); // hello
}
&nums[1..4] borrows elements at indices 1, 2, and 3 (the range is start-inclusive, end-exclusive, exactly like for i in 0..n). No data is copied -- middle is just a pointer and a length pointing into the original vector's buffer, which is why it is cheap and why the original nums must stay alive as long as the slice does (the borrow checker enforces that for you). The string case works identically, giving you a &str view into part of the String.
The real payoff is in function signatures. A function that takes &[i32] (a slice) instead of &Vec<i32> is strictly more useful, because both a Vec and a plain array will coerce into a slice automatically:
fn largest(items: &[i32]) -> i32 {
let mut biggest = items[0];
for &n in items {
if n > biggest {
biggest = n;
}
}
biggest
}
fn main() {
let owned = vec![3, 7, 2, 9, 4];
let array = [1, 5, 8, 2];
println!("largest of owned {}", largest(&owned)); // 9 (Vec -> slice)
println!("largest of array {}", largest(&array)); // 8 (array -> slice)
}
One largest function, and it happily accepts a Vec and a fixed-size array, because &owned and &array both coerce to &[i32]. Had we typed the parameter as &Vec<i32>, the array would have been rejected and the function needlessly picky. The rule of thumb: take a slice, not a reference to a specific collection, whenever your function only needs to read a sequence. It is the single most common piece of Rust API taste, and now you know why. (It still only works for i32 today -- making largest accept any comparable type is a job for generics, which are coming very soon.)
Try it yourself
Three exercises, easy to chewier, as always. Full solutions open the next episode.
Write a function
evens(nums: &[i32]) -> Vec<i32>that takes a slice and returns a newVeccontaining only the even numbers (a valuenis even whenn % 2 == 0). Build it by looping and pushing. Call it frommainonvec![1, 2, 3, 4, 5, 6]and print the result -- you should get[2, 4, 6]. Notice how you take a slice as input but return an ownedVec.Using a
HashMap<char, u32>and the entry API, writechar_frequency(text: &str) -> HashMap<char, u32>that counts how many times each character appears in a string. Iterate withtext.chars(), and use*map.entry(c).or_insert(0) += 1for each character. Test it on"hello"and confirm thatlmaps to 2 andhmaps to 1.Write
first_word(s: &str) -> &strthat returns a string slice covering only the first word of the input (everything up to the first space, or the whole string if there is no space). Hint: find the space's byte index withs.find(' '), which returns anOption<usize>, then return&s[0..index]when there is a space ands(the whole slice) when there is not. Test it on"hello world"(expect"hello") and"single"(expect"single").
Exercise 3 is the one to sit with -- returning a slice that borrows from the input is a genuinely important pattern, and it will quietly set up the lifetime ideas we tackle a few episodes from now.
So what did we actually cover?
Vec<T>is the growable array. Build it withVec::new()+pushor thevec![]macro; it tracks both length and capacity, andwith_capacitylets you allocate once when you know the size ahead of time.- Ownership rides along:
.iter()borrows,.iter_mut()borrows mutably, and looping over the bare value moves the collection. The borrow checker turns C++'s iterator invalidation bug into a compile error you fix instantly. HashMap<K, V>is the key-value lookup table (use std::collections::HashMapfirst). It has no guaranteed order, and the entry API (*map.entry(k).or_insert(0) += 1) is the idiomatic update-or-insert in a single line.Stringis a UTF-8 byte buffer..len()counts bytes, not characters, which is why you cannot index it withs[0]; walk it with.chars()or.char_indices()instead.- Slices (
&[T]and&str) borrow a contiguous window without copying. Prefer&[T]parameters over&Vec<T>so both vectors and arrays can call your function.
Collections are where Rust stops feeling like a toy and starts feeling like a language you could ship a real tool in. You can now hold many values, look them up by key, chew through text correctly, and pass cheap borrowed windows around -- all with the ownership system quietly guaranteeing none of it dangles or corrupts. Next time we start writing code that works across many types at once instead of hard-coding i32 everywhere -- the moment largest grows up and works for anything you can compare. With ownership, errors, and collections all in hand, you are more than ready for it.
Bedankt voor het lezen, en tot de volgende keer! ;-)
Leave Learn Rust Series (#7) - Collections to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Rust Series (#7) - Collections
- Learn AI Series (#137) - Foundation Models
- Learn Zig Series (#117) - Binary Search Variations
- Learn Rust Series (#6) - Error Handling
- Learn AI Series (#136) - Mini Project: Production AI Platform
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Rust Series (#5) - Structs & Enums
- Learn AI Series (#135) - Building AI Teams and Processes
- Learn Rust Series (#4) - Control Flow & Pattern Matching
- Learn AI Series (#134) - AI Infrastructure Economics