Vectors and Strings in Rust
Working with Rust's growable collection types, and the two-flavor string system that trips up almost every newcomer.
3 min read
Arrays, from earlier in this course, have a fixed size known at compile time. Vec<T> is their growable counterpart, and it's the collection you'll reach for by far the most often.
Vectors
fn main() {
let mut numbers: Vec<i32> = Vec::new();
numbers.push(1);
numbers.push(2);
numbers.push(3);
// or, more commonly, the vec! macro:
let numbers = vec![1, 2, 3];
println!("{}", numbers[0]); // panics if out of bounds
println!("{:?}", numbers.get(5)); // None — safe, no panic
}numbers[0] panics if the index is out of range — appropriate when an out-of-bounds index means a bug in your program. numbers.get(5) instead returns an Option<&i32> — Some(&value) or None — appropriate when an out-of-range index is an expected possibility you want to handle gracefully. The Option and Result lesson right after this one covers that pattern in depth.
Iterating follows the same for syntax from earlier, and can borrow mutably to modify in place:
fn main() {
let mut numbers = vec![1, 2, 3];
for n in &mut numbers {
*n *= 2;
}
println!("{:?}", numbers); // [2, 4, 6]
}*n dereferences the mutable reference to reach the actual value being pointed to, so the multiplication modifies the vector's contents rather than the reference itself.
Strings: String vs &str
Rust has two main string types, and understanding the difference is essential:
String— an owned, growable, heap-allocated string. You create and modify it.&str("string slice") — a borrowed view into string data, either part of aStringor a literal baked into the compiled binary.
fn main() {
let s1: &str = "hello"; // string literal, a &str
let s2: String = String::from("hello"); // owned String
let s3: &str = &s2; // borrowing a String as a &str
}Function parameters conventionally take &str rather than String, since a &str can accept either a literal or a borrowed String — it's the more flexible, more common choice:
fn greet(name: &str) {
println!("Hello, {name}!");
}
fn main() {
greet("Ada"); // literal
greet(&String::from("Grace")); // borrowed String
}Building and combining strings
fn main() {
let mut s = String::from("Hello");
s.push_str(", world");
s.push('!');
println!("{s}");
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1 is moved here and can no longer be used
println!("{s3}");
// format! is usually clearer, and never takes ownership of its arguments:
let s4 = format!("{s2}{s2}");
}Strings are UTF-8, and that changes indexing
Rust strings are valid UTF-8, and characters can take a variable number of bytes — which is exactly why s[0] doesn't compile for a String. Indexing by byte position could land in the middle of a multi-byte character and produce garbage. Instead, iterate explicitly over what you actually mean:
fn main() {
let s = "héllo";
for c in s.chars() {
println!("{c}");
}
println!("byte length: {}", s.len()); // 6, not 5 — 'é' is 2 bytes
}This is more upfront friction than "héllo"[1] in a language that lets you index by character, but it means Rust strings can never silently split a character in half, a subtle source of data corruption bugs in languages that allow raw byte indexing.
Test what you just learned
4 quick questions. Get all of them right to unlock the next lesson.
You can take the quiz without an account — logging in just lets your result count toward your progress.