Rust Slices: Views into Collections
A slice is a dynamically-sized view into a contiguous sequence of elements. Unlike arrays or vectors, slices don't own their data—they're references that borrow from an existing collection. This...
Key Insights
- Slices are borrowed views into contiguous memory that provide safe, zero-cost access to portions of collections without transferring ownership
- Understanding slice syntax and range operators is essential for writing idiomatic Rust code that works efficiently with arrays, vectors, and strings
- String slices require special care due to UTF-8 encoding—always slice on character boundaries or use safe methods like
get()to avoid panics
What Are Slices?
A slice is a dynamically-sized view into a contiguous sequence of elements. Unlike arrays or vectors, slices don’t own their data—they’re references that borrow from an existing collection. This makes them incredibly useful for working with portions of data without copying or transferring ownership.
The slice type is written as &[T] for an immutable slice of type T, or &mut [T] for a mutable slice. The most common slices you’ll encounter are string slices (&str) and byte slices (&[u8]), but you can create slices from any contiguous collection.
Here’s the basic syntax:
fn main() {
let arr = [1, 2, 3, 4, 5, 6];
let slice = &arr[1..4]; // References elements at indices 1, 2, 3
println!("{:?}", slice); // [2, 3, 4]
let vec = vec![10, 20, 30, 40, 50];
let vec_slice = &vec[2..]; // From index 2 to the end
println!("{:?}", vec_slice); // [30, 40, 50]
}
The key insight is that slice and vec_slice are just references—they don’t allocate new memory or copy data. They’re pointers to existing memory along with a length.
Slice Types and Syntax
Rust provides flexible range syntax for creating slices. Understanding these variations will make your code more expressive:
fn main() {
let data = [0, 1, 2, 3, 4, 5];
let all = &data[..]; // Entire array
let from_start = &data[..3]; // First 3 elements: [0, 1, 2]
let to_end = &data[2..]; // From index 2 onward: [2, 3, 4, 5]
let middle = &data[1..4]; // Indices 1-3: [1, 2, 3]
// Inclusive range (note the = sign)
let inclusive = &data[1..=3]; // Indices 1-3: [1, 2, 3]
}
Mutable slices allow you to modify the underlying data while still maintaining borrow checker guarantees:
fn main() {
let mut numbers = vec![1, 2, 3, 4, 5];
{
let slice = &mut numbers[1..4];
slice[0] = 10; // Modifies numbers[1]
slice[2] = 30; // Modifies numbers[3]
}
println!("{:?}", numbers); // [1, 10, 3, 30, 5]
}
String slices are ubiquitous in Rust. The &str type is actually a slice into UTF-8 encoded bytes:
fn main() {
let s = String::from("hello world");
let hello = &s[0..5]; // "hello"
let world = &s[6..11]; // "world"
// String literals are also &str
let literal: &str = "I'm a string slice";
}
Common Slice Operations
Slices come with a rich set of methods for common operations. These methods are zero-cost abstractions—they compile down to efficient machine code:
fn main() {
let data = [1, 2, 3, 4, 5, 6, 7, 8];
let slice = &data[..];
println!("Length: {}", slice.len());
println!("Empty: {}", slice.is_empty());
println!("First: {:?}", slice.first()); // Some(&1)
println!("Last: {:?}", slice.last()); // Some(&8)
// Split at index 4
let (left, right) = slice.split_at(4);
println!("Left: {:?}, Right: {:?}", left, right);
// Process in chunks of 3
for chunk in slice.chunks(3) {
println!("Chunk: {:?}", chunk);
}
// Output: [1,2,3], [4,5,6], [7,8]
// Sliding windows of size 3
for window in slice.windows(3) {
println!("Window: {:?}", window);
}
// Output: [1,2,3], [2,3,4], [3,4,5], ...
}
Slice patterns in match expressions are particularly powerful:
fn process(slice: &[i32]) {
match slice {
[] => println!("Empty"),
[x] => println!("Single element: {}", x),
[first, .., last] => println!("First: {}, Last: {}", first, last),
}
}
fn main() {
process(&[]);
process(&[42]);
process(&[1, 2, 3, 4, 5]);
}
Slices and Ownership
Slices follow Rust’s borrowing rules strictly. When you create a slice, you’re borrowing from the underlying collection, which means you can’t modify the original while the slice exists:
fn main() {
let mut vec = vec![1, 2, 3, 4];
let slice = &vec[1..3];
// This won't compile:
// vec.push(5); // Error: cannot borrow `vec` as mutable
println!("{:?}", slice); // slice must be used before vec can be modified
// Now we can modify vec again
vec.push(5); // OK - slice is no longer in scope
}
This is the borrow checker protecting you from data races and iterator invalidation. If you could modify the vector while a slice exists, the slice might point to deallocated memory after the vector reallocates.
Mutable slices have even stricter rules—you can only have one mutable reference:
fn main() {
let mut data = [1, 2, 3, 4];
let slice1 = &mut data[0..2];
// let slice2 = &mut data[2..4]; // Error: cannot borrow as mutable more than once
slice1[0] = 10;
}
String Slices and UTF-8
String slicing is a common source of panics for Rust beginners. Because strings are UTF-8 encoded, you can’t slice at arbitrary byte positions—you must slice at character boundaries:
fn main() {
let s = String::from("Hello, 世界");
// This panics! '世' is 3 bytes, we're slicing mid-character
// let bad = &s[7..8]; // thread 'main' panicked
// Safe approaches:
// 1. Use get() which returns Option
match s.get(7..10) {
Some(slice) => println!("Got: {}", slice),
None => println!("Invalid UTF-8 boundary"),
}
// 2. Iterate over characters
for (i, c) in s.chars().enumerate() {
println!("{}: {}", i, c);
}
// 3. Iterate over bytes when you need them
for byte in s.bytes() {
println!("{:02x}", byte);
}
}
When working with string slices, prefer methods that respect character boundaries:
fn truncate_safe(s: &str, max_chars: usize) -> &str {
match s.char_indices().nth(max_chars) {
Some((idx, _)) => &s[..idx],
None => s,
}
}
fn main() {
let text = "Hello, 世界!";
println!("{}", truncate_safe(text, 7)); // "Hello, "
}
Performance Considerations
Slices are a zero-cost abstraction. The compiler generates the same code for slice operations as you’d write with raw pointers in C, but with safety guarantees.
Passing slices to functions avoids expensive copies:
// Inefficient - clones the entire vector
fn sum_clone(v: Vec<i32>) -> i32 {
v.iter().sum()
}
// Efficient - just passes a pointer and length
fn sum_slice(v: &[i32]) -> i32 {
v.iter().sum()
}
fn main() {
let data = vec![1, 2, 3, 4, 5];
// This requires cloning
// let total = sum_clone(data.clone());
// This is zero-cost
let total = sum_slice(&data);
println!("Sum: {}", total);
}
For iteration, slices and iterators often compile to identical code, but slices give you random access:
fn process_slice(data: &[i32]) {
// Random access
if data.len() > 5 {
println!("Fifth element: {}", data[4]);
}
// Iteration
for &item in data {
println!("{}", item);
}
}
Practical Patterns
The most common pattern is writing functions that accept slices instead of concrete types. This makes your functions work with arrays, vectors, and other slices:
fn find_max(values: &[i32]) -> Option<i32> {
values.iter().copied().max()
}
fn main() {
let arr = [1, 5, 3, 2];
let vec = vec![10, 20, 15];
println!("Array max: {:?}", find_max(&arr));
println!("Vector max: {:?}", find_max(&vec));
println!("Partial vec: {:?}", find_max(&vec[1..]));
}
Returning slices requires careful lifetime management. You can only return slices that borrow from the input:
fn first_word(s: &str) -> &str {
for (i, &byte) in s.as_bytes().iter().enumerate() {
if byte == b' ' {
return &s[..i];
}
}
s
}
fn main() {
let sentence = "Hello world from Rust";
println!("First word: {}", first_word(sentence));
}
Slices excel at parsing and tokenization:
fn parse_key_value(line: &str) -> Option<(&str, &str)> {
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() == 2 {
Some((parts[0].trim(), parts[1].trim()))
} else {
None
}
}
fn main() {
let config = "max_connections = 100";
if let Some((key, value)) = parse_key_value(config) {
println!("{} -> {}", key, value);
}
}
Slices are fundamental to idiomatic Rust. They provide safe, efficient access to data without ownership transfer, enabling you to write flexible APIs that work with any contiguous collection. Master slices, and you’ll write more efficient, more flexible Rust code.