Collections: Vec, String, HashMap
Vec, String, and HashMap are the three collections you'll reach for constantly — and each one hides a gotcha worth understanding early.
You've already met fixed-size arrays, whose length is baked into their type and known at compile time — useful, but a poor fit for 'I don't know how many of these I'll have until the program runs.' The standard library's growable collections fill that gap, and three of them cover the overwhelming majority of everyday Rust: Vec<T> for an ordered, resizable list; String for owned, growable UTF-8 text; and HashMap<K, V> for key-to-value lookups. All three live on the heap, all three can grow and shrink at runtime, and all three still give you the ownership and borrowing guarantees you've already learned — nothing about 'dynamic' means 'unchecked.'
A Vec<T> starts empty with Vec::new() (or pre-filled with the vec![...] macro) and grows with .push(value), which appends to the end, and shrinks with .pop(), which removes and returns the last element as an Option<T> — Some(value) if there was one, None if the vector was already empty. You can read an element by position with square brackets, numbers[0], and iterate the whole thing with a for loop over &numbers, which borrows each element instead of taking ownership of the vector. Reserve owning iteration (for n in numbers) for when you're done with the Vec afterward and genuinely want to consume it element by element.
That square-bracket indexing is convenient, but it panics — immediately and unconditionally — if the index is out of bounds, because Rust would rather crash loudly than silently read garbage memory or wrap around like some languages do. When the index isn't guaranteed to be valid — user input, a computed offset, anything you haven't already checked — reach for .get(index) instead, which returns Option<&T>: Some(&value) for a valid index, None for an out-of-range one, letting you handle the miss instead of crashing on it. The rule of thumb: use [] when being out of bounds would itself be a bug worth crashing on, and .get() whenever the index's validity is genuinely in question.
Rust has two string types doing two different jobs, and the split maps directly onto ownership. String is owned and growable — it manages its own heap buffer, can be built up with push_str or +, and is what you reach for when you need to own text or build it piece by piece. &str is a borrowed view into UTF-8 text living somewhere else — a string literal baked into the binary ("hello", effectively &'static str), or a slice into an existing String — and it's what functions should accept as a parameter whenever they only need to read text, since a &str parameter happily accepts both a literal and a borrowed String without forcing an allocation on the caller.
Both types store their bytes as valid UTF-8 under the hood, and UTF-8 is a variable-width encoding: ASCII characters like 'a' take one byte, while characters like 'é' or emoji can take two, three, or four. That's precisely why neither String nor &str supports my_string[0] as 'give me the first character' — a numeric index would be a byte offset, and there's no guarantee that offset lands on a character boundary rather than splitting one multi-byte character in half, which would produce a chunk of bytes that isn't valid UTF-8 at all. Instead, you're explicit about which unit you mean: .len() gives you the byte length, .chars().count() gives you the character count (and they can genuinely differ, as with "café", which is 5 bytes but 4 characters), and .chars().next() gets you the first character safely, one full character at a time regardless of its byte width.
HashMap<K, V> rounds out the trio for when position doesn't matter but lookup by key does. .insert(key, value) adds or overwrites an entry, and .get(&key) returns Option<&V> — Some if the key exists, None if it doesn't — following the same 'no silent nulls' pattern you've now seen everywhere in this course. The entry API is the idiomatic way to handle 'insert a default if missing, then update' in one step without looking the key up twice: map.entry(key).or_insert(0) returns a mutable reference to the existing value, or inserts 0 first and returns a reference to that, which you can then increment in place — a pattern you'll use constantly for counting, grouping, and accumulating.
Notice that all three collections are generic — Vec<T>, HashMap<K, V> — which is what lets one Vec implementation serve Vec<i32>, Vec<String>, and Vec<Rectangle> alike without duplicating a single line of code. You've been using generics informally this whole lesson without naming them; the next lesson makes that mechanism explicit, and pairs it with traits, which describe what a type can do rather than what data it holds — together, they're how Rust writes one function or one collection that works correctly across many types, without giving up any of the compile-time checking you've relied on so far.
fn main() {let mut numbers: Vec<i32> = Vec::new();numbers.push(10);numbers.push(20);numbers.push(30);println!("first = {}", numbers[0]); // indexing panics if the index is out of boundsif let Some(last) = numbers.pop() {println!("popped {}", last);}for n in &numbers {println!("n = {}", n);}// get() returns Option instead of panicking on an out-of-range index.match numbers.get(10) {Some(value) => println!("value at 10: {}", value),None => println!("index 10 is out of bounds"),}}
Building a Vec with push/pop, iterating it by reference, and contrasting panicking [] indexing with the Option-returning get() for an out-of-range index.
fn main() {let owned: String = String::from("hola, "); // owned, growablelet borrowed: &str = "mundo!"; // borrowed string slice, often 'staticlet mut greeting = owned;greeting.push_str(borrowed);println!("{}", greeting);let word = "café"; // 'c', 'a', 'f' are 1 byte each in UTF-8; 'é' takes 2 bytesprintln!("byte length: {}", word.len());println!("char count: {}", word.chars().count());// word[0] would not compile: String/&str do not support direct integer indexing,// precisely because a byte offset can land in the middle of a multi-byte character.if let Some(first_char) = word.chars().next() {println!("first char: {}", first_char);}}
String (owned, growable) versus &str (borrowed), and why UTF-8's variable-width encoding makes "café".len() (bytes) differ from its character count.
use std::collections::HashMap;fn main() {let mut scores: HashMap<String, i32> = HashMap::new();scores.insert(String::from("Blue"), 10);scores.insert(String::from("Yellow"), 50);match scores.get("Blue") {Some(score) => println!("Blue: {}", score),None => println!("Blue: no score yet"),}// entry API: insert a default only if the key is missing, then update it in place.let team = String::from("Blue");let count = scores.entry(team).or_insert(0);*count += 1;for (key, value) in &scores {println!("{}: {}", key, value);}}
HashMap insert and get return Option the same way Vec's get() does, and the entry API inserts a default only when a key is missing before updating it in place.
🧠 Check your understanding
0/1 · 0/1 answered1. Why does `my_string[0]` fail to compile for a Rust String, even though `my_vec[0]` compiles fine for a Vec<T>?