Work with Rust's dynamic data structures - Vec, String, and HashMap.
Collections store multiple values. Unlike arrays and tuples, collection data lives on the heap. That means the size can change at runtime.
Rust's standard library includes several collections, but three dominate day-to-day programming: Vec<T>, String, and HashMap<K, V>. Let's master each one.
A vector is a growable array. You'll use them constantly.
// Empty vector with explicit type
let v: Vec<i32> = Vec::new();
// Macro for initialization with values
let v = vec![1, 2, 3];
// Type inferred from first push
let mut v = Vec::new();
v.push(5); // Now Rust knows it's Vec<i32>
The vec! macro is syntactic sugar for creating and initializing in one step. Use it whenever you have initial values.
Use Vec::new() when you'll build up the vector incrementally. Use vec![...] when you know the initial values upfront.
let v = vec![1, 2, 3, 4, 5];
// Direct indexing - panics if out of bounds
let third = &v[2];
// .get() method - returns Option<&T>
let third = v.get(2); // Some(&3)
let tenth = v.get(10); // None
Two ways to read, two different philosophies.
Use [] when out-of-bounds means something is fundamentally broken in your logic. The panic is a feature, not a bug. It crashes fast rather than propagating invalid data.
Use .get() when the index might legitimately be out of bounds. Maybe it comes from user input, or you're probing for the existence of an element.
What does v.get(10) return if the vector only has 5 elements?
Here's where ownership gets interesting with collections.
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0]; // Immutable borrow of the vector
v.push(6); // ERROR! Mutable borrow while first exists
println!("{first}");
Wait. We only borrowed the first element. Why can't we push to the end?
When you take a reference to any element, you're borrowing the entire vector. This might seem overly strict, but there's a critical reason.
Think about what push might do. If the vector is at capacity, it needs to allocate new memory and copy all elements to the new location. After that, first would point to freed memory. A dangling pointer.
The borrow checker prevents this with the rules you already know. No special vector logic needed. The same ownership rules that prevent use-after-free in simple cases prevent it here too.
This is Rust's philosophy: simple, composable rules that work across all contexts.
let v = vec![100, 32, 57];
// Read-only iteration
for i in &v {
println!("{i}");
}
// Mutable iteration
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50; // Dereference to modify
}
Note the *i dereference operator. The iterator gives you references, so you need to dereference to modify the actual value.
The for loop desugars into a call to .into_iter(). The vector reference type &Vec<T> implements the IntoIterator trait, so for i in &v becomes something like:
let mut iter = (&v).into_iter();
while let Some(i) = iter.next() {
// loop body
}
Inside the loop, the iterator hands you non-overlapping references one at a time. You can modify slot 0 because by the time you're working on it, the iterator has moved on. It's lending you elements sequentially, not simultaneously.
One thing you cannot do: push or remove elements during iteration.
for i in &v {
v.push(*i); // ERROR! Can't mutably borrow while iterating
}
This would require mutable access to the whole vector while the iterator holds an immutable borrow. The borrow checker says no.
Strings in Rust trip up many newcomers. Let's demystify them.
Before diving into String, let's understand the foundation.
A u8 is just a raw byte: an unsigned 8-bit integer with values 0-255. It knows nothing about text encoding.
UTF-8 is an encoding scheme that uses sequences of u8 bytes to represent characters. A single character might be 1, 2, 3, or 4 bytes.
Think of it like bricks (u8) vs a wall built with a specific pattern (UTF-8). Same material, but one has semantic structure.
String is fundamentally a wrapper around Vec<u8>. But it's not just any bytes. It's bytes with a guarantee.
// Vec<u8> makes no promises about its contents
// Could be an image, network packet, or anything
let bytes = vec![104, 101, 108, 108, 111];
// String GUARANTEES its bytes are valid UTF-8 at all times
let s = String::from("hello");
This is a powerful Rust pattern: encoding invariants in the type system. A String can never contain invalid UTF-8. The compiler enforces this, not your discipline.
Watch what happens when converting between them:
let bytes = vec![104, 101, 108, 108, 111];
let s = String::from_utf8(bytes); // Returns Result! (might fail)
let s = String::from("hello");
let bytes: Vec<u8> = s.into_bytes(); // Always succeeds
Vec<u8> to String might fail because the bytes might not be valid UTF-8. It returns a Result.
String to Vec<u8> always succeeds because valid UTF-8 is valid bytes. Rust knows you're weakening the guarantee, which is always safe.
This surprises everyone coming from other languages:
let hello = "hello";
let h = hello[0]; // ERROR! Rust won't allow this
Why would Rust forbid something so basic?
Consider this string:
let hello = "Zdravstvuyte"; // Russian greeting
In UTF-8, each Cyrillic character takes 2 bytes. "Z" is bytes 0-1, "d" is bytes 2-3, and so on. If hello[0] returned a byte, you'd get half a character. Garbage.
In UTF-8, a "character" can be 1 to 4 bytes. Indexing by byte would give you arbitrary slices of characters. Rust refuses to guess what you want.
Instead, be explicit about what you're iterating:
// Iterate over Unicode scalar values (characters)
for c in "Zd".chars() {
println!("{c}"); // "Z", "d"
}
// Iterate over raw bytes
for b in "Zd".bytes() {
println!("{b}"); // 90, 100 (ASCII values)
}
// Create empty
let mut s = String::new();
// From string literals
let s = "hello".to_string();
let s = String::from("hello");
// Append
let mut s = String::from("hello");
s.push_str(" world"); // Append string slice
s.push('!'); // Append single character
// Concatenation
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1 is moved! Can't use it after
s1 + &s2 takes ownership of s1. The signature is essentially fn add(self, s: &str) -> String. After the concatenation, s1 is gone.
For complex concatenation, use format!:
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
// Ugly and moves s1
let s = s1 + "-" + &s2 + "-" + &s3;
// Clean and doesn't move anything
let s = format!("{s1}-{s2}-{s3}");
format! is like println! but returns a String instead of printing. It takes references, so your original strings stay valid.
HashMaps store key-value pairs with O(1) average lookup time.
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Red"), 50);
Unlike Vec and String, HashMap needs an explicit use statement. It's not in the prelude.
let team_name = String::from("Blue");
let score = scores.get(&team_name); // Returns Option<&V>
.get() returns Option<&V>. A common pattern handles the None case:
let score = scores.get("Blue").copied().unwrap_or(0);
Breaking this down:
.get() returns Option<&i32>.copied() converts Option<&i32> to Option<i32> (copies the value out).unwrap_or(0) returns the value or 0 if Nonefor (key, value) in &scores {
println!("{key}: {value}");
}
Order is arbitrary. HashMaps don't maintain insertion order.
A common pattern is "insert if missing, or update if present." The naive approach:
// TWO hash lookups - inefficient
if let Some(v) = scores.get_mut("Blue") {
*v += 1;
} else {
scores.insert(String::from("Blue"), 1);
}
This hashes the key twice: once to check if it exists, once to insert or update. For a hot path, this waste adds up.
The entry API does it in one lookup:
// ONE hash lookup - efficient
scores.entry(String::from("Blue")).or_insert(0);
// Insert if missing, then modify
*scores.entry(String::from("Blue")).or_insert(0) += 1;
entry() returns an Entry enum with two variants:
enum Entry<'a, K, V> {
Occupied(OccupiedEntry<'a, K, V>),
Vacant(VacantEntry<'a, K, V>),
}
The Entry isn't just carrying metadata. It's carrying an actual pointer/handle into the map's internal storage. Methods like or_insert() operate on that handle directly. No rehashing needed.
For more complex logic:
scores
.entry(String::from("Blue"))
.and_modify(|v| *v += 1)
.or_insert(1);
This reads as: "Find the entry for Blue. If it exists, add 1. If it doesn't, insert 1."
Let's count word frequencies in a string. This is the canonical HashMap use case.
use std::collections::HashMap;
let text = "hello world wonderful world";
let mut map = HashMap::new();
for word in text.split_whitespace() {
let count = map.entry(word).or_insert(0);
*count += 1;
}
println!("{map:?}");
// {"world": 2, "hello": 1, "wonderful": 1}
or_insert returns a mutable reference to the value. We dereference and increment it. Each unique word gets counted in a single pass.
What HashMap method efficiently inserts a default value if the key is missing, OR returns the existing value if present - all in one hash lookup?
Let's see how these collections interact in a real scenario: building an inverted index.
use std::collections::HashMap;
fn build_index(documents: Vec<String>) -> HashMap<String, Vec<usize>> {
let mut index: HashMap<String, Vec<usize>> = HashMap::new();
for (doc_id, doc) in documents.iter().enumerate() {
for word in doc.split_whitespace() {
index
.entry(word.to_lowercase())
.or_insert_with(Vec::new)
.push(doc_id);
}
}
index
}
This creates a map from words to the document IDs containing them. Notice:
Vec<String> holds our documentsHashMap<String, Vec<usize>> maps words to document indicesentry().or_insert_with() lazily creates vectors only when neededCollections own their contents. When a HashMap<String, Vec<usize>> is dropped, all its strings and vectors are dropped too. No manual cleanup needed.
You've now mastered Rust's three essential collections:
Vec<T> - Your go-to dynamic array. Remember that borrowing any element borrows the whole vector, because push might reallocate.
String - A UTF-8 guaranteed Vec<u8>. No indexing because bytes aren't characters. Use .chars() or .bytes() to iterate explicitly.
HashMap<K, V> - Key-value storage with O(1) lookup. Use the entry API for efficient insert-or-update patterns.
The ownership rules you learned earlier apply directly here. Collections own their contents. Borrowing rules prevent iterator invalidation. Type-level guarantees (like String's UTF-8 invariant) make invalid states unrepresentable.
Simple rules, composable across all contexts. Whether it's a single String or a HashMap of Vecs, the same ownership and borrowing rules apply. Master them once, use them everywhere.
You've made it through Elementary Rust. Let's recap the journey:
Foundation (Lessons 1-3): You learned Rust's toolchain, its expression-based syntax, and how variables and functions work.
Memory Safety (Lessons 4-7): You understood why memory management matters, mastered ownership and borrowing, and discovered how slices provide safe references into collections.
Custom Types (Lessons 8-9): You built your own types with structs and enums, and learned how pattern matching makes working with data elegant and exhaustive.
Organization (Lessons 10-11): You structured code with modules and packages, then put it all together with dynamic collections.
The ownership system might have felt foreign at first. That's normal. Every Rustacean goes through the same learning curve. But now you understand the core concepts that make Rust unique: compile-time memory safety without garbage collection.
This course covered the fundamentals. Rust has much more to offer: error handling with Result, generics, traits, lifetimes, async programming, and more. The official Rust Book at doc.rust-lang.org/book is an excellent next step.
Welcome to the Rust community. Happy coding!