09. Vec, Slices, and Strings
📋 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.
Growable Arrays with Vec
Reach for Vec<T> first. It's the collection you'll use most in Rust: a single type, packed together in a heap buffer that grows as you push into it.
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.
Already have a fixed-size array? Turn it into a Vec with Vec::from() or .into().
fn main() {
let arr = [1, 2, 3, 4, 5];
let v1 = Vec::from(arr);
println!("{:?}", v1); // [1, 2, 3, 4, 5]
let v2: Vec<i32> = arr.into();
println!("{:?}", v2); // [1, 2, 3, 4, 5]
}Both copy the array's elements from the stack into the Vec's heap-allocated buffer. The original array is still on the stack (since i32 is Copy), but the Vec now owns its own heap copy.
With owned types like String, the behavior is different — elements are moved, not copied:
fn main() {
let arr = [String::from("hello"), String::from("world")];
let v = Vec::from(arr);
println!("{:?}", v); // ["hello", "world"]
// println!("{:?}", arr); // ❌ ERROR: arr was moved — String isn't Copy
}String owns heap data, so Vec::from(arr) takes ownership of each string. The original array is consumed and can't be used again. No cloning of the actual character data happens — the strings just move into the Vec's buffer.
If you have a slice (&[T]) instead of an owned array, you can't move out of it — it's just a borrowed view. Use .to_vec() to clone each element into a new owned Vec:
fn main() {
let v = vec![String::from("hello"), String::from("world")];
let slice: &[String] = &v;
let cloned = slice.to_vec(); // clones each String
println!("{:?}", v); // still works — v wasn't moved
println!("{:?}", cloned); // independent copy
}.to_vec() requires T: Clone — it always clones because a borrowed view can't give up ownership of data it doesn't own.
Accessing Elements
What happens when you ask for the 100th element of a 4-element vector? It depends how you ask. Indexing with [] panics on out-of-bounds. .get() hands you an Option instead.
fn main() {
let scores = vec![85, 92, 78, 96];
// Direct indexing — copies the value (i32 is Copy), panics if out of bounds
let first = scores[0]; // first is i32, not a reference
println!("First: {}", first); // First: 85
// .get() returns Option<&i32> — a reference to the value inside the vec
match scores.get(10) {
Some(val) => println!("Found: {}", val), // val is &i32
None => println!("Index out of bounds!"), // Index out of bounds!
}
}You can also use if let instead of a full match when handling .get():
fn main() {
let scores = vec![85, 92, 78, 96];
if let Some(val) = scores.get(2) { // val is &i32 (reference into the vec)
println!("Found: {}", val); // Found: 78 — println auto-dereferences
} else {
println!("Out of bounds!");
}
}.get() returns Option<&T>, a reference to the element inside the Vec. The value stays in the vector — you're just looking at it. Same pattern as HashMap.get().
A few more common operations:
fn main() {
let colors = vec!["red", "green", "blue"];
println!("{}", colors.len()); // 3
println!("{}", colors.is_empty()); // false
println!("{}", colors.contains(&"green")); // true — &"green" because contains() takes a reference
println!("{}", colors.contains(&"pink")); // false
}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
A vector works great as a stack. You push onto the end, pop off the end, and iterate through everything in between.
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 — num is &i32
for num in &stack {
print!("{} ", num); // 1 2 3 — println auto-dereferences &i32
}
println!();
// Iterating with mutable references — num is &mut i32
for num in &mut stack {
*num *= 2; // *num dereferences &mut i32 to modify the actual value
}
println!("{:?}", stack); // [2, 4, 6]
}.pop() returns Option<T> because the vector might be empty. Iteration with & borrows each element without taking ownership. When you iterate with &mut, each num is a mutable reference — you need *num to dereference it and change the value it points to.
Need a queue (FIFO) instead of a stack? Use VecDeque — it supports push/pop from both ends in O(1):
use std::collections::VecDeque;
fn main() {
let mut queue = VecDeque::new();
queue.push_back(1);
queue.push_back(2);
queue.push_back(3);
println!("{:?}", queue.pop_front()); // Some(1) — first in, first out
println!("{:?}", queue.pop_front()); // Some(2)
println!("{:?}", queue); // [3]
}Removing Elements
You can remove by index, filter in place, or clear everything:
fn main() {
let mut items = vec![10, 20, 30, 40, 50];
// Remove by index — shifts everything after it
items.remove(1);
println!("{:?}", items); // [10, 30, 40, 50]
// Keep only elements that match a condition
items.retain(|&x| x > 20);
println!("{:?}", items); // [30, 40, 50]
// Remove everything
items.clear();
println!("{:?}", items); // []
}remove(i) is O(n) because elements shift to fill the gap. If order doesn't matter, swap_remove(i) is O(1) — it swaps the last element into the gap instead of shifting.
Capacity vs Length
A vector tracks two numbers, not one. Length is how many elements it holds. Capacity is how much memory it has reserved. Push past the capacity and the vector reallocates a bigger buffer.
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.
Slicing
Slicing gives you a borrowed view into a Vec or String without copying data. Use range syntax with &:
fn main() {
let numbers = vec![10, 20, 30, 40, 50];
let middle: &[i32] = &numbers[1..4];
println!("{:?}", middle); // [20, 30, 40]
let first_two = &numbers[..2];
println!("{:?}", first_two); // [10, 20]
let last_two = &numbers[3..];
println!("{:?}", last_two); // [40, 50]
}1..4 means index 1, 2, 3 (end is exclusive). Omit the start to begin at 0, omit the end to go to the last element.
Slicing with &v[..] panics if the indices are out of bounds. Use .get(range) for safe access:
fn main() {
let numbers = vec![10, 20, 30, 40, 50];
// &numbers[0..10]; // 💥 panic: index out of bounds
if let Some(slice) = numbers.get(0..10) { // slice would be &[i32]
println!("{:?}", slice);
} else {
println!("Out of bounds!"); // Out of bounds!
}
if let Some(slice) = numbers.get(1..3) { // slice is &[i32] (reference to a portion)
println!("{:?}", slice); // [20, 30]
}
}Same rule as single element access: [] panics, .get() returns Option.
Owning a Slice
A slice (&[T]) is a reference. Calling .clone() on it copies the reference, not the data behind it:
fn main() {
let v = vec![1, 2, 3, 4];
let slice = &v[1..3];
let cloned = slice.clone(); // another &[i32] — still borrows v
let owned = slice.to_vec(); // Vec<i32> — independent allocation
println!("{:?}", cloned); // [2, 3]
println!("{:?}", owned); // [2, 3]
}.clone() always copies the value it's called on. A Vec is data, so cloning it copies the heap data. A &[T] is a pointer, so cloning it copies the pointer. To go from "reference to someone else's data" to "my own data", use .to_vec() or .to_owned().
Strings: Owned vs Borrowed
String is the string type you build up at runtime: growable, UTF-8, and owned on the heap. That's the difference from &str, which just borrows text that lives elsewhere (usually static memory for literals, or the heap when slicing a String).
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.
Choosing Between String and &str
So which one should your function ask for? &str borrows a view into string data. String owns its data on the heap. That one difference decides which you want.
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.
Notice &owned in the call — Rust automatically converts &String to &str through deref coercion. You don't need to do anything special, just pass &your_string and it works.
UTF-8 String Indexing
Try to grab the first character with my_string[0] and Rust stops you. Why? Strings are UTF-8, where a single character can span 1 to 4 bytes, so indexing is ambiguous: do you mean the first byte or the 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.
String slicing works with byte ranges, but panics if you slice in the middle of a multi-byte character:
fn main() {
let s = String::from("hello world");
let word: &str = &s[0..5];
println!("{}", word); // hello
let rest: &str = &s[6..];
println!("{}", rest); // world
}If you're working with non-ASCII text, iterate with .chars() instead of slicing.
Key Takeaways
- Vec
is your go-to growable array: push,pop,get,remove,retain Vec::from(arr)/arr.into()copiesCopytypes or moves owned types into a heap buffer.to_vec()clones from a borrowed slice — always clones, requiresT: Clone- Use
.get()for safe access that returnsOptioninstead of panicking — works for single elements and ranges *refdereferences a mutable reference to modify the value it points toVec::with_capacityavoids reallocations when you know the approximate size- Slices (
&[T]) are borrowed views —.to_vec()or.to_owned()creates an independent copy - String owns heap-allocated UTF-8 text; &str borrows it
- Accept
&strin function params — Rust auto-converts&Stringto&str - You can't index strings by position because UTF-8 characters vary in byte length — use
.chars()
🎁 You can store any single type in a Vec. But what if you need to associate keys with values, count occurrences, or check membership in O(1) time?