07. 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.
Vec — The Growable Array
Vec<T> is Rust's most common collection. It stores elements of a single type in a contiguous, heap-allocated buffer that resizes automatically.
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
You can index a vector with [] or use .get() for safe access. Indexing panics on out-of-bounds; .get() returns an Option.
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
Vectors support adding and removing from the end, plus easy iteration.
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 has a length (how many elements it holds) and a capacity (how much memory is allocated). When length exceeds capacity, the vector reallocates.
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.
String — Heap-Allocated Text
String is Rust's growable, UTF-8 encoded string type. It lives on the heap, unlike &str which is a reference to string data stored elsewhere.
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.
&str vs String — When to Use Which
&str is a borrowed view into string data. String owns its data on the heap.
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 and Why You Can't Index by Character
Rust strings are UTF-8 encoded. A single character might be 1–4 bytes, so my_string[0] isn't allowed — it would be ambiguous whether you mean the first byte or 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.
HashMap<K, V> — Key-Value Storage
HashMap stores data as key-value pairs with O(1) average lookup time.
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
The entry API lets you insert a value only if the key doesn't exist, or modify existing values. It's perfect for counting and default-value patterns.
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}
}.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
The .collect() method transforms any iterator into a collection. The target type is usually inferred or annotated 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}
}.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, and generics let you write code that works with any type meeting those requirements.