$elementary.dev
[Courses][About]|
$elementary.dev

Text-first, project-driven courses for developers who want to level up by building real things.

CoursesAbout
Stay Updated

Get notified about new courses and articles.

// No spam, unsubscribe anytime

© 2026 elementary.dev
PrivacyTerms
// Course

Elementary Rust

11/11
  • 01Hello, Rust
  • 02Variables and Types
  • 03Functions and Control Flow
  • 04Why Memory Management Matters
  • 05Ownership: Rust's Solution
  • 06Borrowing and References
  • 07Slices
  • 08Structs
  • 09Enums and Pattern Matching
  • 10Organizing Rust Projects
  • 11Collections
courses/elementary-rust/11-collections

Collections

Work with Rust's dynamic data structures - Vec, String, and HashMap.

~30 min|#Lesson 11/11

Collections

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.


Vec: The Dynamic Array

A vector is a growable array. You'll use them constantly.

Creating Vectors

// 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.

// Tip-When to use which

Use Vec::new() when you'll build up the vector incrementally. Use vec![...] when you know the initial values upfront.

Reading Elements

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.

// Challenge

Vec Access

What does v.get(10) return if the vector only has 5 elements?

$

The Borrow Checker and Vectors

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?

// Warning-The whole vector is borrowed

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.

Iterating Over Vectors

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.

// Info-How for loops work

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.


String: More Than Just Text

Strings in Rust trip up many newcomers. Let's demystify them.

Understanding UTF-8 and Bytes

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.

Vec vs String: Encoding Invariants

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");
// Tip-Type-level guarantees

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.

Why No String Indexing

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.

// Danger-A byte is not a character

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)
}

Creating and Manipulating Strings

// 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
// Warning-The + operator moves

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.


HashMap: The Key-Value Store

HashMaps store key-value pairs with O(1) average lookup time.

Creating and Using

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.

Reading Values

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 None
// Info-Iterating HashMaps
for (key, value) in &scores {
    println!("{key}: {value}");
}

Order is arbitrary. HashMaps don't maintain insertion order.

The entry API: Efficient Insert-or-Update

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;
// Info-The Entry enum

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."

A Practical Example: Word Frequency

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.

// Challenge

Word Frequency

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?

$

Putting It Together

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 documents
  • HashMap<String, Vec<usize>> maps words to document indices
  • entry().or_insert_with() lazily creates vectors only when needed
  • Each word's vector grows as we find more documents containing it
// Info-Ownership in collections

Collections own their contents. When a HashMap<String, Vec<usize>> is dropped, all its strings and vectors are dropped too. No manual cleanup needed.


Summary

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.

// Tip-The pattern

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.


Course Complete

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.

// Info-Where to go next

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!

<-[prev]Organizing Rust Projects
Progress: 0%
// On this page
  • Vec: The Dynamic Array
  • Creating Vectors
  • Reading Elements
  • The Borrow Checker and Vectors
  • Iterating Over Vectors
  • String: More Than Just Text
  • Understanding UTF-8 and Bytes
  • Vec vs String: Encoding Invariants
  • Why No String Indexing
  • Creating and Manipulating Strings
  • HashMap: The Key-Value Store
  • Creating and Using
  • Reading Values
  • The entry API: Efficient Insert-or-Update
  • A Practical Example: Word Frequency
  • Putting It Together
  • Summary
  • Course Complete