08. Collections
📋 Jump to Takeaways🎁 Arrays are great when you know exactly how many items you need at compile time. But what if you're reading user input, parsing a file, or building a list dynamically? Rust's standard library gives you powerful, heap-allocated collections that grow and shrink at runtime.
Growable Arrays with Vec
Reach for Vec<T> first. It's the collection you'll use most in Rust: a single type, packed together in a heap buffer that grows as you push into it.
fn main() {
let mut numbers: Vec<i32> = Vec::new();
numbers.push(10);
numbers.push(20);
numbers.push(30);
println!("{:?}", numbers); // [10, 20, 30]
// You can also use the vec! macro
let colors = vec!["red", "green", "blue"];
println!("{:?}", colors); // ["red", "green", "blue"]
}You create an empty vector with Vec::new() or a pre-filled one with the vec! macro. The type parameter T is inferred from what you push into it.
Accessing Elements
What happens when you ask for the 100th element of a 4-element vector? It depends how you ask. Indexing with [] panics on out-of-bounds. .get() hands you an Option instead.
fn main() {
let scores = vec![85, 92, 78, 96];
// Direct indexing — panics if out of bounds
let first = scores[0];
println!("First: {}", first); // First: 85
// Safe access with .get()
match scores.get(10) {
Some(val) => println!("Found: {}", val),
None => println!("Index out of bounds!"), // Index out of bounds!
}
}Use .get() when the index might be invalid. Use [] when you're certain the index is in range and want a panic to signal a bug.
Push, Pop, and Iteration
A vector works great as a stack. You push onto the end, pop off the end, and iterate through everything in between.
fn main() {
let mut stack = vec![1, 2, 3];
stack.push(4);
println!("{:?}", stack); // [1, 2, 3, 4]
let popped = stack.pop(); // Returns Option<T>
println!("{:?}", popped); // Some(4)
// Iterating by reference
for num in &stack {
print!("{} ", num); // 1 2 3
}
println!();
// Iterating with mutable references
for num in &mut stack {
*num *= 2;
}
println!("{:?}", stack); // [2, 4, 6]
}.pop() returns Option<T> because the vector might be empty. Iteration with & borrows each element without taking ownership.
Capacity vs Length
A vector tracks two numbers, not one. Length is how many elements it holds. Capacity is how much memory it has reserved. Push past the capacity and the vector reallocates a bigger buffer.
fn main() {
let mut v = Vec::with_capacity(5);
v.push(1);
v.push(2);
println!("len: {}, capacity: {}", v.len(), v.capacity());
// len: 2, capacity: 5
}Use Vec::with_capacity(n) when you know roughly how many elements you'll store. This avoids repeated reallocations.
Heap-Allocated Strings
String is the string type you build up at runtime: growable, UTF-8, and owned on the heap. That's the difference from &str, which just borrows text that lives somewhere else.
fn main() {
let mut greeting = String::from("Hello");
greeting.push_str(", world!");
greeting.push('!');
println!("{}", greeting); // Hello, world!!
// format! for complex concatenation
let name = "Rust";
let message = format!("{} loves {}", greeting, name);
println!("{}", message); // Hello, world!! loves Rust
}Use push_str to append a string slice and push to append a single character. The format! macro builds strings without taking ownership of its arguments.
Choosing Between String and &str
So which one should your function ask for? &str borrows a view into string data. String owns its data on the heap. That one difference decides which you want.
fn print_greeting(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
let owned = String::from("Alice"); // Heap-allocated, owned
let borrowed: &str = "Bob"; // Points to static data
print_greeting(&owned); // String coerces to &str
print_greeting(borrowed); // Already &str
}Accept &str in function parameters, it works with both String and string literals. Use String when you need to own or mutate the text.
UTF-8 String Indexing
Try to grab the first character with my_string[0] and Rust stops you. Why? Strings are UTF-8, where a single character can span 1 to 4 bytes, so indexing is ambiguous: do you mean the first byte or the first character?
fn main() {
let hello = String::from("Здравствуйте"); // Russian
println!("bytes: {}", hello.len()); // bytes: 24
println!("chars: {}", hello.chars().count()); // chars: 12
// Iterate over characters
for c in hello.chars().take(3) {
print!("{} ", c); // З д р
}
println!();
}Use .chars() to iterate by Unicode scalar value and .bytes() for raw bytes. This design prevents subtle bugs with multi-byte characters.
Key-Value Storage with HashMap
Need to look things up by name instead of position? That's a HashMap: key-value pairs with O(1) average lookups.
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
scores.insert("Alice", 95);
scores.insert("Bob", 87);
// Access with .get() — returns Option<&V>
if let Some(score) = scores.get("Alice") {
println!("Alice scored {}", score); // Alice scored 95
}
// Iteration
for (name, score) in &scores {
println!("{}: {}", name, score);
}
}You must use std::collections::HashMap, it's not in the prelude. Keys must implement Eq and Hash.
The Entry API
Counting things is where the entry API shines. It inserts a value when the key is missing, or lets you tweak the one that's already there, all in one call.
use std::collections::HashMap;
fn main() {
let text = "hello world hello rust hello";
let mut word_count = HashMap::new();
for word in text.split_whitespace() {
let count = word_count.entry(word).or_insert(0);
*count += 1;
}
println!("{:?}", word_count);
// {"hello": 3, "world": 1, "rust": 1} (key order varies between runs)
}.entry(key).or_insert(default) returns a mutable reference to the value. If the key didn't exist, it inserts the default first.
Collecting Iterators into Collections
.collect() is the bridge from iterators back to collections. Run any iterator through it and you get a Vec, a HashMap, or whatever type you ask for (inferred, or spelled out with a turbofish).
fn main() {
// Collect a range into a Vec
let numbers: Vec<i32> = (1..=5).collect();
println!("{:?}", numbers); // [1, 2, 3, 4, 5]
// Filter and collect
let evens: Vec<i32> = (1..=10).filter(|n| n % 2 == 0).collect();
println!("{:?}", evens); // [2, 4, 6, 8, 10]
// Collect into a HashMap
use std::collections::HashMap;
let pairs: HashMap<&str, i32> = vec![("a", 1), ("b", 2)]
.into_iter()
.collect();
println!("{:?}", pairs); // {"a": 1, "b": 2} (key order varies between runs)
}.collect() is one of the most versatile methods in Rust. It works because many collections implement the FromIterator trait.
Key Takeaways
- Vec
is your go-to growable array, use push,pop,get, and iteration - Use
.get()for safe access that returnsOptioninstead of panicking Vec::with_capacityavoids reallocations when you know the approximate size- String owns heap-allocated UTF-8 text; &str borrows it
- Accept
&strin function params, it works with bothStringand literals - You can't index strings by position because UTF-8 characters vary in byte length
- HashMap provides O(1) key-value lookups, use the entry API for insert-or-update
.collect()transforms any iterator into a Vec, HashMap, or other collection
🎁 Your collections can hold any type, but what if you want to define shared behavior that works across different types without inheritance? Next up: traits let you describe what a type can do, so completely unrelated types can share one interface.