TL;DR
Learn to use Rust's standard collections including Vec, HashMap, and HashSet with iterators
Key concepts
- Rust collections
- Rust Vec HashMap
- Rust HashSet
- Rust vectors
- Rust standard collections
Collections in Rust
Collections are data structures that can hold multiple values. Unlike arrays and tuples, which are stored on the stack with a fixed size, collections store their data on the heap and can grow or shrink at runtime. Rust's standard library provides several powerful collection types. In this lesson, we will focus on the three most commonly used: Vec, HashMap, and HashSet.
What You'll Learn
By the end of this lesson you will build a small report out of a pile of records: store them in a growable list, count them by key in a map, and print a summary whose rows come out in the same order every time you run it. That last requirement is the one that catches people, and it is not a formatting detail — a map's iteration order here is deliberately unstable between runs of the same binary, so a report built straight off it is a test that passes four times and fails on the fifth.
The Vec half of this is what the capstone's taskwork stores everything in: its tasks live in one owned Vec for as long as the program runs, and every view it prints is a selection over that list. The map half is not in the capstone at all — you are learning it because grouping by key is the first thing you will reach for the moment a report needs more than one bucket, and because the failure mode it hides is the one below. You arrive here able to say who owns a value and when it is dropped (Ownership & Borrowing) — which matters immediately, because a collection owns its elements, so taking one out, looking at one, and handing the whole thing to a function are three different operations with three different consequences.
Vectors: Vec<T>
A Vec<T> is a growable array. It stores elements of a single type in a contiguous block of memory and is the most frequently used collection in Rust:
fn main() {
// Create a vector with the vec! macro
let mut scores = vec![85, 92, 78, 95, 88];
// Add elements
scores.push(91);
scores.push(76);
// Access elements by index (panics if out of bounds)
println!("First score: {}", scores[0]);
// Safe access with .get() returns Option<&T>
match scores.get(100) {
Some(score) => println!("Score at 100: {}", score),
None => println!("No score at index 100"),
}
// Length and capacity
println!("Total scores: {}", scores.len());
println!("Capacity: {}", scores.capacity());
// Remove the last element
if let Some(last) = scores.pop() {
println!("Removed last score: {}", last);
}
println!("Scores: {:?}", scores);
}
Those two access styles in the middle — scores[0] and scores.get(100) — are not stylistic alternatives. Indexing panics when the position does not exist; .get() returns an Option so you can decide what absence means. HashMap offers exactly the same pair, and the panic message it produces is much less obvious. Before meeting it, recall what that Option return buys you.
Recall
Without scrolling up: in Option and Result you learned that Option<T> makes absence part of the type, so the compiler will not let you use a value without deciding what happens when it is missing. Apply that to scores[0] versus scores.get(0). What is the real difference between them?
Both check at run time; the difference is what they hand back. Indexing promises a &T, and the only way to keep that promise when the element is missing is to crash — so it panics. .get() promises Option<&T>, which forces the absent case into the open before you can touch the value. Pick by intent: index when an out-of-range access would mean your own code is broken, and .get() when a missing element is legitimate input you must handle. The next program gets that choice wrong, in the HashMap form where the panic message is least helpful. Commit a hypothesis before you change anything:
Debug
total_for should sum the quantities of the requested parts, counting anything unstocked as 0. The first call works and the second crashes. Say exactly which line panics and why the message says what it says, then fix it so both calls report 14.
use std::collections::HashMap;
/// Sum the quantities of the requested parts.
/// A part that is not stocked counts as 0 and must NOT stop the report.
fn total_for(stock: &HashMap<&str, u32>, wanted: &[&str]) -> u32 {
let mut total = 0;
for part in wanted {
total += stock[part];
}
total
}
fn main() {
let mut stock: HashMap<&str, u32> = HashMap::new();
stock.insert("bolts", 10);
stock.insert("nuts", 4);
let stocked = ["bolts", "nuts"];
let with_gap = ["bolts", "washers", "nuts"];
println!("stocked -> {}", total_for(&stock, &stocked));
println!("with gap -> {}", total_for(&stock, &with_gap));
assert_eq!(total_for(&stock, &stocked), 14, "both stocked parts should total 14");
assert_eq!(total_for(&stock, &with_gap), 14, "an unstocked part counts as 0, not a crash");
println!("All checks passed.");
}Expected output: stocked -> 14
with gap -> 14
All checks passed.
The panic is on total += stock[part];. Indexing a HashMap goes through its Index impl, which promises a &u32 — so with no entry to return it panics with no entry found for key, a message that names neither the key nor anything about your logic. The first call survives only because every requested part happened to be stocked. The fix states the doc comment's actual intent in code: total += stock.get(part).copied().unwrap_or(0); — get hands back an Option<&u32>, copied turns that into an owned Option<u32>, and unwrap_or(0) puts the policy for a missing part in one visible place.
Reading a Panic, Which Is Not Like Reading a Compile Error
Everything you have read about rustc's diagnostics so far applies to messages produced before the program runs. This one is produced while it is running, and it is a much poorer message. Here it is, exactly as the lane prints it:
stocked -> 14
thread 'main' (576) panicked at /tmp/main.rs:8:23:
no entry found for key
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Notice first that the program got somewhere. stocked -> 14 was printed and is still there: a panic stops execution at the point it happens, so everything already written to the output stays written. That first line is evidence — it tells you the first call succeeded and the second did not, which narrows the question before you read a word of the error.
Now take an inventory of what a compile error gives you and this does not. There is no --> with a source excerpt, no ^^^ underline marking the offending expression, no secondary spans, no help: suggestion, and no error code — so there is nothing to rustc --explain and nothing stable to search for except the message text itself. What you get is four facts: which thread died, the file and position (/tmp/main.rs:8:23), the panic message, and a hint about backtraces. The number in parentheses is the operating system's thread id, so it differs on every run and means nothing to you.
The position is the most useful part, and it is easy to misread. 8:23 is the line and column where the panic happened. Count to line 8 of the program above and it is total += stock[part];, inside total_for — column 23 lands on the stock[part] indexing expression itself. It is not the call in main that supplied the bad input, and not the line that inserted the wrong data. A panic location is the site of the failed operation, and on a job like this one the interesting question is almost always the one it does not answer: which caller, with which value? That is what the note: line is for, and it is the step you take at a terminal: RUST_BACKTRACE=1 adds the chain of calls that reached the panic, so you can see main calling total_for and work out which of the two calls was the one that died. It does not run on this page — the Run button sets no environment variables — but it is the next thing to reach for anywhere else.
Then read the message itself critically. no entry found for key names no key and no map. It cannot: the Index implementation that panics is generic over key types and has nothing it can safely print. So the message tells you the class of failure and nothing about the instance, and reconstructing the instance is your job — from the position, from the backtrace, and by looking at what the failing expression was indexing. Treat a message this thin as evidence about the code rather than about the data: an operation whose failure cannot even name what it failed on is an operation being asked to do something it has no vocabulary for. That is the real signal here, and the fix follows from it — the moment a key comes from input rather than from the map itself, use get and say what absence means.
Iterating Over Vectors
Vectors support multiple ways to iterate, each suited to different situations:
fn main() {
let names = vec![
String::from("Alice"),
String::from("Bob"),
String::from("Charlie"),
];
// Immutable iteration with a reference
println!("== All names ==");
for name in &names {
println!(" {}", name);
}
// Mutable iteration
let mut prices = vec![10.0, 20.0, 30.0, 40.0];
for price in &mut prices {
*price *= 1.1; // Apply 10% increase
}
println!("Updated prices: {:?}", prices);
// Consuming iteration (takes ownership)
let numbers = vec![1, 2, 3, 4, 5];
let total: i32 = numbers.into_iter().sum();
println!("Sum: {}", total);
// numbers is no longer available here because into_iter consumed it
}
Iterator Adaptors: map, filter, and More
Iterators in Rust are lazy -- they do nothing until you consume them. Adaptor methods like .map() and .filter() transform iterators, while consuming methods like .collect() and .sum() produce final results:
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// filter: keep only even numbers
let evens: Vec<i32> = numbers.iter()
.filter(|&&n| n % 2 == 0)
.cloned()
.collect();
println!("Evens: {:?}", evens);
// map: transform each element
let doubled: Vec<i32> = numbers.iter()
.map(|&n| n * 2)
.collect();
println!("Doubled: {:?}", doubled);
// Chaining: filter then map
let large_squares: Vec<i32> = numbers.iter()
.filter(|&&n| n > 5)
.map(|&n| n * n)
.collect();
println!("Squares of numbers > 5: {:?}", large_squares);
// find: get the first match
let first_even = numbers.iter().find(|&&n| n % 2 == 0);
println!("First even: {:?}", first_even);
// any and all: boolean checks
let has_negative = numbers.iter().any(|&n| n < 0);
let all_positive = numbers.iter().all(|&n| n > 0);
println!("Has negative: {}, All positive: {}", has_negative, all_positive);
}
HashMap<K, V>
A HashMap stores key-value pairs with O(1) average lookup time. Keys must implement the Eq and Hash traits. Most standard types like String, integers, and booleans work as keys:
use std::collections::HashMap;
fn main() {
let mut book_ratings: HashMap<String, f64> = HashMap::new();
// Insert entries
book_ratings.insert(String::from("The Rust Book"), 4.8);
book_ratings.insert(String::from("Programming Rust"), 4.7);
book_ratings.insert(String::from("Rust in Action"), 4.5);
// Access a value
let title = "The Rust Book";
if let Some(rating) = book_ratings.get(title) {
println!("{}: {}/5.0", title, rating);
}
// Update: insert overwrites existing values
book_ratings.insert(String::from("The Rust Book"), 4.9);
// Update only if key does not exist
book_ratings
.entry(String::from("Zero to Production"))
.or_insert(4.6);
// Iterate over key-value pairs
println!("\nAll ratings:");
for (book, rating) in &book_ratings {
println!(" {}: {}", book, rating);
}
// Check if a key exists
println!(
"\nHas 'The Rust Book': {}",
book_ratings.contains_key("The Rust Book")
);
println!("Total books: {}", book_ratings.len());
}
Counting with HashMap
A very common pattern is using a HashMap to count occurrences. The entry API makes this clean and efficient:
use std::collections::HashMap;
fn main() {
let text = "the quick brown fox jumps over the lazy dog the fox";
let mut word_counts: HashMap<&str, u32> = HashMap::new();
for word in text.split_whitespace() {
let count = word_counts.entry(word).or_insert(0);
*count += 1;
}
// Sort by count (descending) for display
let mut counts_vec: Vec<(&&str, &u32)> = word_counts.iter().collect();
counts_vec.sort_by(|a, b| b.1.cmp(a.1));
println!("Word frequencies:");
for (word, count) in counts_vec {
println!(" {:>6}: {}", word, count);
}
}
Four ways of putting a value into a HashMap appear in this lesson, and they behave differently in ways the method names only half suggest. Trace the program below — the closure in the last one prints, so you can see whether it runs at all.
Predict
Four writes hit the same map: insert on an existing key, or_insert on an existing key, or_insert on a new key followed by a mutation through the returned reference, and or_insert_with on an existing key. What are the three printed lines, and does ' [closure ran]' appear?
use std::collections::HashMap;
fn main() {
let mut stock: HashMap<&str, i32> = HashMap::new();
stock.insert("bolts", 10);
// A: insert on an existing key
stock.insert("bolts", 4);
// B: entry().or_insert on an existing key
stock.entry("bolts").or_insert(99);
// C: entry().or_insert on a NEW key, then mutate through the returned &mut
let nuts = stock.entry("nuts").or_insert(0);
*nuts += 7;
// D: or_insert_with on an existing key — is the closure even called?
stock.entry("bolts").or_insert_with(|| {
println!(" [closure ran]");
1000
});
println!("bolts = {:?}", stock.get("bolts"));
println!("nuts = {:?}", stock.get("nuts"));
println!("count = {}", stock.len());
}bolts = Some(4), nuts = Some(7), count = 2, and the closure never runs. insert writes unconditionally, so line A replaces the 10 with 4. or_insert writes only when the entry is vacant — line B finds bolts occupied and leaves it alone — but it always returns a &mut to whatever is there afterwards, which is what makes line C's counting idiom work: *nuts += 7 writes straight into the map with no second lookup. or_insert_with takes a closure rather than a value, so an occupied entry means the default is never even computed; reach for it when the default is expensive (a Vec::new, an allocation, a parse) and for or_insert when it is a plain literal.
Building a Count in One Place
The entry API returns a mutable reference into the map, and it is worth assembling one of these by hand, because the ordering constraint it creates is not the usual one:
Arrange the code
These five lines create an empty count map, get a mutable handle to one key's running total, add to it through that handle, then read the finished number back out and print it. The pieces are shuffled. Put them in the order that runs and prints 'bolts = 12' — then answer what makes this order forced: one of the four moves you might try is rejected for a completely different reason than the other three, so say which pairs are ordered by a name not existing yet, and which pair is ordered by something else.
let running = totals.entry("bolts").or_insert(0);*running += 12;let stocked = totals["bolts"];println!("bolts = {}", stocked);let mut totals: HashMap<&str, u32> = HashMap::new();
HashSet<T>
A HashSet is a collection of unique values. It is essentially a HashMap where you only care about the keys. HashSets are great for membership testing and set operations:
use std::collections::HashSet;
fn main() {
let mut frontend: HashSet<&str> = HashSet::new();
frontend.insert("Alice");
frontend.insert("Bob");
frontend.insert("Charlie");
frontend.insert("Alice"); // Duplicate, will be ignored
let mut backend: HashSet<&str> = HashSet::new();
backend.insert("Bob");
backend.insert("Diana");
backend.insert("Eve");
println!("Frontend team: {:?}", frontend);
println!("Backend team: {:?}", backend);
// Set operations
let fullstack: HashSet<&&str> = frontend.intersection(&backend).collect();
println!("Fullstack (both teams): {:?}", fullstack);
let all_devs: HashSet<&&str> = frontend.union(&backend).collect();
println!("All developers: {:?}", all_devs);
let frontend_only: HashSet<&&str> = frontend.difference(&backend).collect();
println!("Frontend only: {:?}", frontend_only);
// Membership testing
println!("Is Alice on frontend? {}", frontend.contains("Alice"));
println!("Is Diana on frontend? {}", frontend.contains("Diana"));
}
Building Collections with Iterators
Iterators and collections work together seamlessly. You can transform data from one collection type to another using .collect():
use std::collections::HashMap;
fn main() {
// Create a HashMap from two vectors using zip
let keys = vec!["name", "language", "level"];
let values = vec!["Rustacean", "Rust", "Intermediate"];
let profile: HashMap<&str, &str> = keys.into_iter()
.zip(values.into_iter())
.collect();
println!("Profile: {:?}", profile);
// Partition a vector into two based on a predicate
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let (evens, odds): (Vec<i32>, Vec<i32>) = numbers.into_iter()
.partition(|&n| n % 2 == 0);
println!("Evens: {:?}", evens);
println!("Odds: {:?}", odds);
// Enumerate: get index alongside value
let fruits = vec!["apple", "banana", "cherry"];
let indexed: Vec<(usize, &&str)> = fruits.iter()
.enumerate()
.filter(|(i, _)| i % 2 == 0)
.collect();
println!("Even-indexed fruits: {:?}", indexed);
}
Grouping with a Map of Vecs
A HashMap whose values are Vecs is how you group records by key, and the entry API is what makes it readable.
Every fence so far in this lesson has had its key line explained for you. This one leaves one line unannotated on purpose — the gradebook.entry(student).or_insert_with(Vec::new).push(score) in the loop. Before you read past the fence, say what that one line does for a student seen for the first time and what it does for a student seen again, and say what or_insert_with has to hand back for the .push() chained onto it to work at all. The rest of the fence keeps its notes.
use std::collections::HashMap;
fn letter_grade(score: f64) -> &'static str {
match score as u32 {
90..=100 => "A",
80..=89 => "B",
70..=79 => "C",
60..=69 => "D",
_ => "F",
}
}
fn main() {
let mut gradebook: HashMap<&str, Vec<f64>> = HashMap::new();
for (student, score) in [
("Alice", 92.0), ("Alice", 88.0), ("Alice", 95.0),
("Bob", 78.0), ("Bob", 82.0), ("Bob", 71.0),
("Charlie", 65.0), ("Charlie", 70.0), ("Charlie", 58.0),
] {
gradebook.entry(student).or_insert_with(Vec::new).push(score);
}
// Sort before displaying: HashMap iteration order is NOT stable.
let mut students: Vec<&&str> = gradebook.keys().collect();
students.sort();
println!("{:<10} {:>8} {:>6}", "Student", "Average", "Grade");
println!("{}", "-".repeat(26));
let mut best: Option<(&str, f64)> = None;
for student in students {
let scores = &gradebook[student];
let average = scores.iter().sum::<f64>() / scores.len() as f64;
println!("{:<10} {:>8.1} {:>6}", student, average, letter_grade(average));
if best.map_or(true, |(_, top)| average > top) {
best = Some((student, average));
}
}
if let Some((name, avg)) = best {
println!("\nTop student: {} with {:.1} average", name, avg);
}
}
First, the withheld line. gradebook.entry(student) does not look the key up and does not insert anything; it hands back an Entry, a value standing for "this key's slot, whether or not it is occupied". or_insert_with(Vec::new) then resolves that slot in one of two ways: for a student seen for the first time it calls Vec::new and puts the empty vector in, and for a student seen again it leaves what is already there alone. Either way it returns the same thing — a &mut Vec<f64> pointing into the map — which is what makes the chained .push(score) append in place rather than into a copy that gets thrown away. The _with matters too: or_insert(Vec::new()) would build a fresh vector on every single iteration and throw most of them away — nine builds for nine rows, of which only three are ever kept, one per student — because an argument is evaluated before the call is made, while a closure is only run at the moment the value is actually needed.
That prints Alice at 91.7 (an A), Bob at 77.0 (a C) and Charlie at 64.3 (a D), in that order, on every run — the sorted key list is what makes "in that order" true. Note also that gradebook[student] is safe here for the reason the retrieval above named: the keys came out of the map itself, so an absent one would mean this code is broken, not that the input was unusual.
The Same Map in Another Language
Nearly every language ships a key-to-value structure, and JavaScript's Map is close enough to Rust's HashMap that the two are easy to conflate. Two of the properties this lesson has leaned on do not survive the crossing:
Transfer
JavaScript's Map and Rust's HashMap both store key-value pairs and both let you look up a key that might not be there. Both programs below were run as written. The Rust one printed its keys in a different order on each of two runs; the JavaScript one printed zebra,apple,mango,bolt both times. Which statement names what genuinely transfers between the two, rather than a resemblance?
// The Rust half, complete and runnable. The JavaScript it is being compared
// with is the same four steps:
// const m = new Map();
// m.set("zebra", 1); m.set("apple", 2);
// console.log(m.get("nope")); // undefined
// console.log([...m.keys()].join(",")); // zebra,apple - every run
use std::collections::HashMap;
fn main() {
let mut m: HashMap<&str, u32> = HashMap::new();
m.insert("zebra", 1);
m.insert("apple", 2);
println!("{:?}", m.get("nope"));
println!("{:?}", m.keys().collect::<Vec<_>>());
}Capstone Milestone
Capstone milestone
The capstone's taskwork keeps every task it has read in one owned Vec and answers questions about it without ever giving it away: a selection returns a list of references INTO that Vec, so the original stays usable and nothing is copied. Build that shape now, while the records are trivial. Read a few lines of text, push one record per good line into an owned Vec while collecting the bad ones separately, then write a selection that hands back borrowed views of the matching records.
Hint: Write the selection second and call it twice from main before believing it. If the second call will not compile, an into_iter has slipped in where iter belonged, and the collection was moved into the first call instead of borrowed.
- One owned Vec holding the good records, built by pushing as you go, with a second Vec collecting the rejected lines rather than aborting on the first one.
- A selection function taking the collection as a slice and returning Vec of references to the matches, not clones — so the caller can still use the original afterwards.
- The count of matches comes from the selection or from len(), never from a counter you increment by hand.
- Calling the selection twice in a row on the same data compiles — the check that you borrowed rather than consumed.
Try It Yourself
Reading about the entry API is not the same as reaching for it by intent. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one builds a frequency map, one reads it safely, and one renders it in a stable order. Run the starter as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
The third function looks like busywork and is not. A HashMap's iteration order is deliberately unspecified and varies between runs of the same binary, because the hasher is seeded randomly at start-up to make collision attacks impractical. Any output derived from iterating a map must therefore be sorted before it is compared or displayed — which is exactly what the last check asserts.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
// A tiny word-frequency workbench built on HashMap.
use std::collections::HashMap;
// TODO 1: count how many times each word appears.
// tally(&["a", "b", "a"]) has 2 entries: "a" -> 2 and "b" -> 1.
// Use the entry API: entry(key).or_insert(0) hands you a &mut u32 for
// that key, inserting the 0 first if the key was absent. Then *count += 1.
fn tally(words: &[&str]) -> HashMap<String, u32> {
let _ = words;
HashMap::new()
}
// TODO 2: return the count for word, or 0 if it was never seen.
// This must NOT panic on an absent word — index syntax counts[word] would.
// get() returns Option<&u32>; copied() turns that into Option<u32>.
fn count_of(counts: &HashMap<String, u32>, word: &str) -> u32 {
let _ = counts;
let _ = word;
0
}
// TODO 3: return every (word, count) pair SORTED by word.
// Collect the map's pairs into a Vec, then sort it. The sort is the point:
// HashMap iteration order is not stable, so an unsorted Vec here would give
// a different answer on different runs.
fn sorted_pairs(counts: &HashMap<String, u32>) -> Vec<(String, u32)> {
let _ = counts;
Vec::new()
}
// --- Build checks: these must all pass. Do not edit below this line. ---
fn main() {
let words = ["fig", "date", "fig", "elder", "fig", "date"];
let counts = tally(&words);
assert_eq!(counts.len(), 3, "tally should hold one entry per DISTINCT word");
assert_eq!(count_of(&counts, "fig"), 3, "fig appears three times");
assert_eq!(count_of(&counts, "date"), 2, "date appears twice");
assert_eq!(count_of(&counts, "quince"), 0, "an absent word counts as 0, not a panic");
assert_eq!(
sorted_pairs(&counts),
vec![
("date".to_string(), 2),
("elder".to_string(), 1),
("fig".to_string(), 3),
],
"sorted_pairs must sort, because HashMap iteration order is not stable"
);
println!("All checks passed.");
println!("Distinct words: {}", counts.len());
println!("Sorted: {:?}", sorted_pairs(&counts));
println!("fig: {}, quince: {}", count_of(&counts, "fig"), count_of(&counts, "quince"));
}Expected output: All checks passed.
Distinct words: 3
Sorted: [("date", 2), ("elder", 1), ("fig", 3)]
fig: 3, quince: 0
Once it passes, try two variations and predict each before running:
- Replace the
entrycall with a plaininsert. Intally, swap the two-line body of the loop forcounts.insert(word.to_string(), 1);. Predict which check breaks before running. The first check still passes — there are still three distinct keys — and the second fails withleft: 1, right: 3.insertoverwrites unconditionally, so every repeat of"fig"resets its count to 1 rather than adding to it. The distinct-key count looks right, which is exactly why this bug survives a casual glance. - Delete the sort. Remove
pairs.sort();fromsorted_pairs. Predict what happens before running — and then run it several times. It is not a stable failure: on most runs the last check fails with the pairs in some scrambled order like[("fig", 3), ("elder", 1), ("date", 2)], but every so often the map's random order happens to match and the whole program passes. A test that passes intermittently is worse than one that always fails, and this is the mechanism behind a great many of them.
Key Takeaways
Vec<T>is Rust's growable array, the most commonly used collectionHashMap<K, V>provides fast key-value lookups with theentryAPI for elegant updatesHashSet<T>stores unique values and supports set operations like union and intersection- Iterators are lazy and must be consumed with methods like
.collect(),.sum(), or.for_each() - Adaptor methods like
.map(),.filter(), and.enumerate()transform iterators without consuming them .collect()can build any collection type -- the type annotation tells Rust which one you want
Pro Tip: When all you need is to look up values by a key,
HashMapis the default choice. When you need to check "is this element in the set?", useHashSet. When order matters, considerBTreeMapandBTreeSet, which keep keys sorted.
Next Steps
Now that you can work with collections, you're ready to tackle lifetimes — Rust's way of ensuring references are always valid, even across complex data structures.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.