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 returns Option instead of panicking
  • Vec::with_capacity avoids reallocations when you know the approximate size
  • String owns heap-allocated UTF-8 text; &str borrows it
  • Accept &str in function params — it works with both String and 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.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy