10.1 Slices as References to a Contiguous Sequence
Right, so we’ve been dealing with ownership and Strings, which is a bit like being handed the title to a car. It’s yours. You drive it, you wreck it, you’re responsible for its scrap metal. But what if you just need to borrow the car for a quick trip to the store? You don’t want the full responsibility of ownership; you just want a specific, contiguous part of it for a little while.
That’s the entire philosophical underpinning of the slice type. It’s a reference to a contiguous sequence of elements in a collection, rather than to the entire collection. It’s you saying, “Hey, I don’t need the whole String, I just want to look at characters from index 5 to index 9.” A slice lets you do that without copying the data or taking ownership. It’s a view, a window, into someone else’s (or your own) data.
The Almighty String Slice
The poster child for slices is the string slice, &str. If a String is the owner of the heap data, a &str is a borrowed reference to a part of it. You create one by using a range within brackets.
let s = String::from("hello world");
// This is a slice referencing "hello"
let hello: &str = &s[0..5];
// And this is a slice referencing "world"
let world: &str = &s[6..11];
Notice the type annotation: &str. It’s a reference (&) to a str. The str is the unsized type that lives somewhere, and the slice is our handle to it. The crucial point here is that the slice doesn’t just contain the starting pointer; it also contains a length. Under the hood, a slice like &str is a fat pointer—it’s actually two values: a pointer to the start of the data and the length of the slice. This is why it’s so powerful; it’s a self-contained view.
Why Not Just Use Indexes Everywhere?
You might be thinking, “Couldn’t I just pass around starting and ending integers?” You could, but you’d be inviting a world of pain. Those integers would be completely disconnected from the original data. What if the String gets modified or dropped? Your integers would point to nonsense or, worse, invalid memory. A slice &str is tied to the lifetime of the String it comes from. The Rust compiler ensures that the String outlives any slices taken from it. This is Rust’s borrow checker doing its magic to prevent dangling pointers. You get both the convenience of a view and memory safety, for free.
The Peril of Byte Indexes, Not Char Indexes
Here’s the first thing that trips up everyone, and it’s where I have to call out the language designers. While brilliant, their choice to use byte indexes for string slicing is a brutal but necessary truth. Strings in Rust are UTF-8 encoded, and characters can be multiple bytes long.
let hello = "Здравствуйте"; // Russian for "hello", because why not.
// Let's try to get the first character, which is 'З' (a two-byte character in UTF-8)
let s = &hello[0..1];
This code will panic at runtime. You’ll get a nice, informative error: thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of \Здравствуйте`’`. This is Rust protecting you from slicing a character in half and creating invalid UTF-8. It’s a sharp edge, but it’s a safe sharp edge—it fails loudly and clearly instead of silently creating garbage data.
The best practice? If you need to work with characters, use the chars() iterator. If you are slicing, you must be certain your indices are on char boundaries. Methods like char_indices() can help you find those boundaries programmatically.
The More General Slice: &[T]
Strings are special because of UTF-8, but the slice concept is universal. You can have a slice of an array or a Vec<T>. The type is written as &[T].
let vec = vec![1, 2, 3, 4, 5];
// Get a slice of the middle three elements
let slice: &[i32] = &vec[1..4]; // This is [2, 3, 4]
assert_eq!(slice, &[2, 3, 4]);
Everything you learned about &str applies here: it’s a fat pointer (start pointer + length), it’s borrowed, and the borrow checker ensures the underlying data lives long enough. This is how you write functions that take a read-only view of a sequence without caring if it’s a Vec, an array, or another slice.
fn sum_of_slice(slice: &[i32]) -> i32 {
slice.iter().sum()
}
// We can pass a Vec...
let total = sum_of_slice(&vec);
// ...or an array...
let array = [1, 2, 3];
let total2 = sum_of_slice(&array);
// ...or even a different slice!
let partial_slice = &vec[0..2];
let total3 = sum_of_slice(partial_slice);
This polymorphism is incredibly powerful and is a cornerstone of idiomatic, efficient Rust library design. You almost always want to take a &[T] (or &str) as an argument instead of a &Vec<T> or &String. It makes your function far more flexible. The only time you shouldn’t is if you genuinely need to query the capacity of the underlying container, which is a rare, niche need.
So, to summarize: slices are your best friend for efficient, safe borrowing. They are the “view” type. Respect the string slice’s byte-indexing rules, and use the general &[T] to write flexible and robust functions.