So far in this series, we have worked with data types that have a fixed size known at compile time, like a u32 integer or a boolean. These are fast and stored neatly on the “Stack.”
But what if you are building an application where you don’t know how much data you will have until the program is actually running? If you are building a To-Do list, you don’t know if the user will add 3 items or 300.
For dynamic, changing data, Rust provides Collections. These are stored on the “Heap,” meaning they can expand and contract at runtime. Today, we will look at the two most common collections: Vectors and Strings.
Let’s set up a new project:
cargo new collections_guide
cd collections_guide
Vectors: The Growable Array
A Vector (Vec<T>) allows you to store a list of items of the same type next to each other in memory. It is similar to an Array in JavaScript or a List in Python.
Open your src/main.rs and let’s create our first Vector.
Creating and Modifying a Vector
Because a Vector is designed to grow, you will almost always declare it as mutable (mut). You can create a new, empty Vector using the Vec::new() function.
fn main() {
// Create a new, empty Vector that holds Strings
let mut tasks: Vec<String> = Vec::new();
// Adding items using the .push() method
tasks.push(String::from("Buy groceries"));
tasks.push(String::from("Finish Rust tutorial"));
tasks.push(String::from("Walk the dog"));
println!("I have {} tasks on my list.", tasks.len());
}
If you run cargo run, it will output: I have 3 tasks on my list.
Note: Because Rust is strictly typed, a Vec<String> can only hold Strings. You cannot push a u32 into it.
The vec! Macro Shortcut
If you already know exactly what data you want to put in your Vector when you create it, you can use the vec! macro for a much cleaner syntax:
fn main() {
// The vec! macro infers the type automatically
let mut high_scores = vec![10500, 9800, 7200];
// You can still push to it later!
high_scores.push(11200);
println!("The scores are: {:?}", high_scores);
}
Reading Safely from a Vector
How do we get data out of a Vector? You might be tempted to use index notation (e.g., high_scores[0] to get the first item).
While you can do that, it is dangerous. If you ask for high_scores[100] and there are only 4 items, your program will instantly panic and crash (an “out of bounds” error).
Instead, Rust provides a safer method using the .get() function. .get() returns an Option enum, which we learned about in the last post!
fn main() {
let servers = vec!["US-East", "EU-West", "Asia-Pacific"];
// .get() takes the index and returns an Option
let request = servers.get(5);
match request {
Some(server_name) => println!("Connecting to {}...", server_name),
None => println!("Error: Server index out of bounds!"),
}
}
Because we used .get() and a match statement, asking for a non-existent item gracefully prints an error instead of crashing the application.
Strings: A Specialized Collection
You have been using String::from() throughout this entire series, but did you know that a String is actually just a specialized Collection? Under the hood, a String is essentially a Vector of bytes (Vec<u8>) that has been verified to be valid UTF-8 text.
Because it behaves like a Vector, you can push and modify it dynamically.
fn main() {
// Creating a mutable, empty String
let mut system_log = String::new();
// Appending a string slice
system_log.push_str("System Booting...");
// Appending a single character
system_log.push('\n');
system_log.push_str("Loading Modules...");
println!("{}", system_log);
}
String Slices (&str) vs. String
This is a major tripping point for beginners, so let’s clarify the difference between the two text types in Rust:
String: Growable, mutable, stored on the Heap, and owned by a variable. (Created viaString::from()orString::new()).&str(String Slice): Fixed size, immutable, and usually a reference to a piece of data stored elsewhere.
When you write hardcoded text like let msg = "Hello";, that is a string slice (&str). It is baked directly into the final executable file of your program. It cannot grow or change.
If you need to modify text, always convert it to an owned String.
Looping Over Collections
The most common thing you will do with a collection is loop through it to process every item. We do this using the for loop we learned in Step 3.
fn main() {
let mut system_alerts = vec![
String::from("Disk Space Low"),
String::from("Updates Available"),
];
// We loop over a REFERENCE to the vector (&system_alerts)
// so the loop doesn't take ownership of the data.
for alert in &system_alerts {
println!("ALERT: {}", alert);
}
// Because we only passed a reference, we can still use the vector here!
system_alerts.clear();
}
Summary
When you don’t know the exact size of your data at compile time, Collections are your go-to tools:
Vectors (
Vec<T>) store dynamic lists of items of the same type.Strings (
String) are essentially Vectors of verified text that can grow and change.Always try to access Vector elements safely using
.get()to prevent “out of bounds” crashes.Loop over Collections using references (
&) to avoid accidentally transferring Ownership.
With control flow, error handling, and now dynamic data storage under your belt, you are ready to start writing highly capable applications. In the next post, we will look at how to organize all this code using Traits and Modules.
Learning Rust
Continue your journey with the next step or visit a previous post you may have missed
Rust: Setting up your development environment
Rust: Cargo and the Anatomy of “Hello, World”
Rust: Variables and Immutability
Rust: Control Flow, Making Decisions with If, Else, and Loops
Rust: Ownership Explained, Memory Safety
Rust: Structs and Enums, Modeling Custom Data
Rust: Pattern Matching, Mastering the Match Operator
Rust: Error Handling, Ditching Exceptions for Result and Option
Rust: Collections, Mastering Vectors and Strings
Rust: Modules and Traits, Organizing Code and Shared Behavior