Slices, Chunks, and Windows

A slice (&[T]) is the most-used borrowed type in Rust after &str, and like &str it's a view: a pointer plus a length, borrowing into data owned elsewhere. Collections Deep Dive covered the owning containers; this tutorial covers the view you pass around instead. The payoff is twofold, accepting &[T] makes a function work with Vec, arrays, and other slices alike, and the slice API (chunks, windows, split_at, binary_search) replaces a lot of error-prone manual indexing with methods that can't go out of bounds.


Accept &[T], Not &Vec<T>

The single most common slice mistake is taking &Vec<T> as a parameter. It needlessly restricts callers, a &Vec<T> accepts only a Vec, while &[T] accepts a Vec, an array, another slice, or anything that derefs to a slice:

// too restrictive: only a Vec works
fn sum(values: &Vec<i32>) -> i32 {
    values.iter().sum()
}

// idiomatic: works with Vec, arrays, slices, all of them
fn sum(values: &[i32]) -> i32 {
    values.iter().sum()
}

sum(&vec![1, 2, 3]);     // &Vec coerces to &[i32] via Deref
sum(&[1, 2, 3]);         // array coerces too
sum(&data[1..4]);        // a sub-slice

This is the same lesson as "accept &str, not &String" from Strings: Choosing the Right Type, applied to collections: a Vec<T> derefs to &[T] (via the Deref mechanism from Deref, AsRef, and Borrow), so taking the slice type costs callers nothing and frees them from being forced to own a Vec. Only take &Vec<T> when you genuinely need Vec-specific methods (you almost never do).


Indexing: get() vs []

Direct indexing (slice[i]) panics on an out-of-bounds index, the same as it does for Vec. When the index might be invalid, get() returns an Option instead, turning a potential crash into a value you handle:

let data = [10, 20, 30];

let x = data[5];          // PANIC: index out of bounds

let y = data.get(5);      // None — no panic
if let Some(v) = data.get(5) {
    println!("{v}");
}

Gotcha: reach for .get() whenever the index comes from anything you don't control, user input, a computed offset, a parsed value, so a bad index is a handled None rather than a process-killing panic. Reserve bare slice[i] for indices you can prove are in range (a loop bound by .len(), a constant). The .first(), .last(), .split_first(), and .split_last() helpers are the panic-free way to peel off ends, all return Option, so an empty slice is handled rather than crashing.


chunks and windows: Two Different Groupings

Two slice methods produce sub-slices, and they're easy to confuse because the difference is subtle but important. chunks(n) splits into non-overlapping groups of n (the last may be shorter); windows(n) produces overlapping sliding views of width n.

let data = [1, 2, 3, 4, 5];

// chunks(2): non-overlapping — for batching, pairing, grid rows
for c in data.chunks(2) {
    println!("{c:?}");      // [1, 2]  [3, 4]  [5]
}

// windows(2): overlapping — for comparing adjacent elements, moving averages
for w in data.windows(2) {
    println!("{w:?}");      // [1, 2]  [2, 3]  [3, 4]  [4, 5]
}

Use chunks to partition data (process records in batches of 100, lay out a matrix row by row); use windows to look at adjacent relationships (is the sequence sorted? compute deltas between neighbors). A common idiom: data.windows(2).all(|w| w[0] <= w[1]) checks whether a slice is sorted in one line. (chunks_exact(n) is a faster variant when you want to drop any short final chunk and let the optimizer assume a fixed width.)


Splitting Without Copying

split_at(mid) divides a slice into two views at an index, no allocation, both halves borrow the original. The &mut version, split_at_mut, is the sanctioned way to get two mutable sub-slices of the same data, which the borrow checker otherwise forbids (Working With the Borrow Checker):

let mut data = [1, 2, 3, 4, 5, 6];

let (left, right) = data.split_at_mut(3);   // two non-overlapping &mut slices
left[0] = 100;
right[0] = 200;        // both mutable at once — legal because they can't overlap

This is exactly the pattern the unsafe tutorial showed std's split_at_mut implementing internally: a safe API over a provably-non-overlapping split. There's also split() / splitn() for dividing on a predicate (like str::split but for any &[T]), and split_first()/split_last() for peeling one element off an end while keeping the rest as a slice, the slice-native way to write head/tail recursion.


binary_search: Logarithmic Lookup on Sorted Data

On a sorted slice, binary_search finds an element in O(log n) instead of the O(n) of a linear contains/iter().position(). Its return type is the clever part: Result<usize, usize>, Ok(i) if found (at index i), and Err(i) if not, where i is where it would be inserted to keep the slice sorted.

let sorted = [10, 20, 30, 40, 50];

match sorted.binary_search(&30) {
    Ok(i)  => println!("found at {i}"),       // Ok(2)
    Err(i) => println!("would insert at {i}"),
}

// the Err index is directly usable to keep a Vec sorted on insert:
let mut v = vec![10, 20, 40];
let target = 30;
if let Err(pos) = v.binary_search(&target) {
    v.insert(pos, target);    // v is now [10, 20, 30, 40], still sorted
}

Gotcha: binary_search is only correct on a slice that's already sorted by the same ordering, on unsorted data it returns meaningless results, silently, with no error. There's no check that the precondition holds; it's on you. If the data isn't kept sorted, a HashSet (Collections Deep Dive) for membership or a linear scan is the safer choice. The Err(insertion_point) return is what makes a sorted Vec a viable ordered structure, but only as long as every insert goes through binary_search to preserve the invariant.


Slice Method Cheat Sheet

You wantUse
A function that accepts Vec/array/slicetake &[T] (not &Vec<T>)
Indexing that can't panic.get(i)Option, .first()/.last()
Non-overlapping batches.chunks(n) / .chunks_exact(n)
Overlapping sliding views.windows(n)
Two mutable halves of one slice.split_at_mut(mid)
Peel one element off an end.split_first() / .split_last()
Fast lookup on sorted data.binary_search(&x) (sorted only!)
Split on a predicate/separator.split(...) / .splitn(...)

The throughline: the slice API is designed so the common operations, grouping, splitting, searching, are methods that respect bounds, not manual for i in 0..len loops where an off-by-one becomes a panic or a bug. Prefer the named method; it states intent and stays in bounds by construction.


Key Takeaways

  • A slice &[T] is a borrowed view (pointer + length). Accept &[T] in function parameters, not &Vec<T>, so callers can pass a Vec, array, or sub-slice without being forced to own a Vec.
  • slice[i] panics out of bounds; use .get(i) (returns Option) for any index you don't control, and .first()/.last()/.split_first() to handle ends without crashing on empty.
  • chunks(n) gives non-overlapping batches (partitioning); windows(n) gives overlapping sliding views (adjacent comparisons). Don't confuse them, windows(2).all(...) is the one-liner sortedness check.
  • split_at_mut is the safe way to get two mutable sub-slices of the same data; split/split_first/split_last divide without copying.
  • binary_search is O(log n) on sorted slices and returns Result<usize, usize>, the Err index is the insertion point that keeps a Vec sorted. It silently misbehaves on unsorted data, so the sorted invariant is your responsibility.