09 — Slices
A slice makes "borrow, don't copy" work for collections. Why does &[T] exist when &Vec<T> compiles fine? A specific ABI trick — the fat pointer.
Under-the-Hood Mechanics
The fat pointer, exactly
main.rs
fn main() {
assert_eq!(std::mem::size_of::<&[i32]>(), 16); // ptr (8) + len (8) on 64-bit
assert_eq!(std::mem::size_of::<&i32>(), 8); // thin pointer, no length needed
assert_eq!(std::mem::size_of::<&str>(), 16); // same layout as &[u8]
let v = vec![1, 2, 3];
let s: &[i32] = &v;
// s is (ptr_to_v[0], 3) — it does NOT know about v's capacity or allocator
}
rust
fn main() {
let arr = [1, 2, 3, 4, 5];
let v = vec![1, 2, 3, 4, 5];
let slice_from_array: &[i32] = &arr; // slice can borrow from an array...
let slice_from_vec: &[i32] = &v; // ...or a Vec...
let slice_from_slice: &[i32] = &v[1..3]; // ...or another slice — all it needs is (ptr, len)
println!("{slice_from_array:?} {slice_from_vec:?} {slice_from_slice:?}");
}
// Contrast: Vec<T> is THREE words (ptr, len, capacity) because it OWNS the allocation.
&str layout and the UTF-8 invariant enforced at the type level
rust
fn main() {
let s = "hello"; // valid UTF-8 by construction (literal)
let bytes: &[u8] = s.as_bytes();
let back = std::str::from_utf8(bytes).unwrap(); // validated — panics/errors on invalid UTF-8
let bad: &[u8] = &[0xFF, 0xFE];
assert!(std::str::from_utf8(bad).is_err()); // safe path REJECTS invalid bytes
// unsafe { std::str::from_utf8_unchecked(bad) }; // bypasses the check — UB the moment
// anything treats the result as valid UTF-8
println!("{s} {back}");
}
Indexing vs slicing: two different runtime operations
rust
fn sum_first_three(s: &[i32]) -> i32 {
s[0] + s[1] + s[2] // three bounds checks, unless LLVM proves len >= 3 and elides them
}
fn sum_first_three_checked(s: &[i32]) -> Option<i32> {
if s.len() < 3 { return None; }
Some(s[0] + s[1] + s[2]) // LLVM CAN now eliminate the redundant checks via range analysis
}
fn sum_first_three_iter(s: &[i32]) -> i32 {
s.iter().take(3).sum() // never indexes at all — nothing to elide, freely vectorizable
}
Cost, Performance, and Trade-Offs
rust
fn takes_slice(_: &[i32]) {} // two register-sized values: pointer + length, no allocation
fn takes_vec_ref(_: &Vec<i32>) {} // same cost to CALL, but forces caller to own a Vec in the first place
fn main() {
let arr = [1, 2, 3];
takes_slice(&arr); // works: stack array, no heap allocation needed
// takes_vec_ref(&arr); // COMPILE ERROR: &[i32; 3] is not &Vec<i32> — Vec-typed params are less general
}
rust
// Bounds checking is cheap but not zero — 5-15% overhead is realistic in tight numeric loops
// that the optimizer can't prove safe. Fix is restructuring, not unsafe get_unchecked as a first move.
fn dot(a: &[f64], b: &[f64]) -> f64 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() // iterator shape LLVM's vectorizer recognizes best
}
rust
fn main() {
let s = "héllo wörld";
// Repeated byte-offset slicing recomputes boundaries every time — compute once instead:
let boundaries: Vec<(usize, char)> = s.char_indices().collect();
println!("{boundaries:?}");
}
Production Failure Modes & Anti-Patterns
Anti-pattern: hand-rolled sub-range splitting instead of split_at_mut
rust
// naive: mid-level dev wants two independently-mutable halves of a buffer
fn process_naive(buf: &mut [f32], mid: usize) {
let left = &mut buf[..mid];
// let right = &mut buf[mid..]; // ERROR: cannot borrow `*buf` as mutable more than once
for x in left { *x *= 2.0; }
}
rust
fn process_right(buf: &mut [f32], mid: usize) {
let (left, right) = buf.split_at_mut(mid); // panics if mid > buf.len(); no UB path
for x in left.iter_mut() { *x *= 2.0; }
for x in right.iter_mut() { *x *= 0.5; }
}
fn process_parallel(buf: &mut [f32], mid: usize) {
let (left, right) = buf.split_at_mut(mid);
std::thread::scope(|s| {
s.spawn(|| for x in left.iter_mut() { *x *= 2.0; });
s.spawn(|| for x in right.iter_mut() { *x *= 0.5; }); // provably non-overlapping, zero sync overhead
});
}
Anti-pattern: byte-offset string slicing on user input
rust
// naive: truncate a display name to 20 "characters"
fn truncate_naive(name: &str) -> &str {
if name.len() > 20 {
&name[..20] // byte length, not char count — panics if byte 20 lands mid-sequence (é, emoji, CJK)
} else {
name
}
}
rust
fn truncate_right(name: &str, max_chars: usize) -> &str {
match name.char_indices().nth(max_chars) {
Some((byte_idx, _)) => &name[..byte_idx],
None => name, // fewer than max_chars characters total
}
// For full correctness on grapheme clusters (emoji + skin-tone modifiers), use `unicode-segmentation`.
}
Anti-pattern: get_unchecked as a premature optimization
rust
// naive: "I profiled this and bounds checks show up, so let's remove them"
fn dot_product_unsafe(a: &[f64], b: &[f64]) -> f64 {
let mut sum = 0.0;
for i in 0..a.len() {
sum += unsafe { a.get_unchecked(i) * b.get_unchecked(i) }; // UB if b is shorter than a
}
sum
}
rust
fn dot_product_safe(a: &[f64], b: &[f64]) -> f64 {
assert_eq!(a.len(), b.len(), "slices must be equal length"); // invariant proven ONCE, at the boundary
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum() // LLVM auto-vectorizes; no manual unsafe needed
}
Architectural Application
rust
fn parse(input: &str) -> Vec<&str> { // composes with in-memory strings, mmap'd files, network buffers
input.split(',').collect()
}
// fn parse_narrow(input: &String) -> Vec<&str> { ... } // forces every caller through an allocation
rust
// Zero-copy parsing: return sub-slices of the ORIGINAL buffer, never allocate new Strings per token.
fn tokenize(input: &str) -> Vec<&str> {
input.split_whitespace().collect()
// Each returned &str borrows from `input` — cheap as an (index, length) pair, made SAFE
// by the lifetime system (next chapter) tying every token's lifetime to the buffer's.
}
rust
fn parallel_sum(data: &mut [i64]) -> i64 {
let mid = data.len() / 2;
let (left, right) = data.split_at_mut(mid); // proved non-overlapping ONCE, up front
let mut left_sum = 0i64;
let mut right_sum = 0i64;
std::thread::scope(|s| {
s.spawn(|| left_sum = left.iter().sum());
s.spawn(|| right_sum = right.iter().sum());
});
left_sum + right_sum
// This is the primitive underneath rayon::slice::ParallelSlice — no lock per element needed.
}
💡 Tips & Tricks
rust
fn main() {
let v = vec![1, 2, 3];
println!("{:?}", v.get(10)); // Option<&T> — no crash while debugging index panics
// println!("{}", v[10]); // would panic immediately
for window in v.windows(2) { // zero-allocation, lazy — no manual for-i-in-0..len loop
println!("{window:?}");
}
for chunk in v.chunks(2) {
println!("{chunk:?}");
}
}
- Idiom: accept
&[T]/&strin signatures, never&Vec<T>/&String— strictly more general at zero cost. - Idiom:
split_at_mutis the only safe way to get two simultaneously mutable, non-overlapping views. - Debug: "byte index N is not a char boundary" always means UTF-8-unsafe slicing — use
.char_indices()instead of a fixed byte offset. - Clippy:
clippy::indexing_slicingflags direct[]indexing in favor of.get().
⚠️ Edge Cases & Gotchas
rust
fn main() {
let arr = [1, 2, 3];
let empty = &arr[0..0]; // valid, length 0, never panics
println!("{:?}", empty.windows(5).count()); // 0 iterations, NOT a panic — easy to forget
// let bad = &arr[..arr.len() + 1]; // panics: off-by-one past the end
let v = vec![10, 20, 30];
println!("{:?}", v.get(10)); // None — safe default for untrusted-input indices
// println!("{}", unsafe { v.get_unchecked(10) }); // silent UB, not a panic
let fixed: &[i32; 3] = &arr;
let dynamic: &[i32] = fixed; // coerces automatically — but NOT the reverse; N is lost
let s = "héllo";
// let c = s[0]; // does not compile — byte indexing is meaningless as "char i"
let c = s.chars().nth(0); // O(n), walks from the start
println!("{c:?}");
}
🧠 Spot the Bug
rust
fn last_three(data: &[i32]) -> &[i32] {
&data[data.len() - 3..]
}
fn main() {
let readings = vec![10, 20];
println!("{:?}", last_three(&readings));
}
What's wrong, and when does it bite?
Answer
rust
// readings has only 2 elements: data.len() - 3 == 2usize - 3
// debug: panics "attempt to subtract with overflow" immediately
// release: wraps to usize::MAX-ish, then fails the subsequent bounds check instead
rust
fn last_three(data: &[i32]) -> &[i32] {
let start = data.len().saturating_sub(3); // clamps to 0 instead of underflowing
&data[start..]
}
The lesson: any usize arithmetic derived from .len() that subtracts a constant is a latent underflow panic on short inputs — reach for saturating_sub/checked_sub any time the subtrahend isn't provably <= the length.
Summary
- A slice is a fat pointer —
(data ptr, length), two machine words, no ownership, no capacity — which is exactly what makes&[T]strictly more general than&Vec<T>as an API parameter. - Indexing and range-slicing both insert a runtime bounds check that LLVM can eliminate when it can prove safety via range analysis (most naturally achieved through iterator adapters, not manual indexing).
&strshares&[u8]'s layout plus a UTF-8 invariant enforced at every safe construction point; byte-offset slicing on user text is a latent panic on any non-ASCII input.split_at_mutis the load-bearing primitive behind safe data-parallel processing over a single buffer — reach for it beforeunsafeany time you need two independently-mutable views into one slice.
Next: Lifetimes — the compile-time vocabulary that makes it safe to hand out a slice (or any reference) in the first place.