When slices need to be sliced
You are building a parser for a binary protocol. The specification says every record is exactly 16 bytes. You have a Vec<u8> filled with raw data from a socket. You need to process each record without copying the bytes into new vectors. Or you are analyzing sensor data and need to check every 5 consecutive readings to detect a spike. Rust slices provide two methods for these jobs, and picking the wrong one leads to subtle bugs or wasted allocations.
chunks(n) splits a slice into non-overlapping pieces of size n. windows(n) creates an iterator of overlapping sub-slices of size n. Both methods return iterators that yield references to the original data. They perform zero allocations. They adjust pointers and lengths to create views into the existing memory.
Concept and analogy
Think of chunks like cutting a loaf of bread. You slice it into pieces of a fixed width. Each piece is distinct. You get a set of separate slices. The last piece might be smaller if the loaf doesn't divide evenly.
Think of windows like a fixed-size frame sliding along a long painting. You hold the frame, look at the view, then slide the frame one step to the right. The new view overlaps heavily with the previous one. You see a moving perspective of the data.
chunks gives you disjoint segments. windows gives you overlapping views. The choice depends on whether your algorithm needs independent blocks or a sliding neighborhood.
Minimal example
Here is the basic usage of both methods. The code prints the sub-slices to show the difference in structure.
fn main() {
let data = [10, 20, 30, 40, 50];
// Split into non-overlapping pairs.
// The iterator yields &[T]. The last chunk handles the remainder.
for chunk in data.chunks(2) {
println!("Chunk: {:?}", chunk);
}
// Slide a window of size 3.
// Each step shifts the start index by one.
for window in data.windows(3) {
println!("Window: {:?}", window);
}
}
The output shows the structural difference clearly. chunks(2) produces [10, 20], [30, 40], and [50]. The last chunk contains the leftover element. windows(3) produces [10, 20, 30], [20, 30, 40], and [30, 40, 50]. Every window has exactly three elements. The iterator stops when the window can no longer fit.
How the iterator works
Both methods return iterators that yield &[T]. The iterator does not allocate new slices. It calculates the start pointer and length for each step based on the original slice header.
For chunks(n), the iterator tracks the current index. Each step advances the index by n. The length of the yielded slice is min(n, remaining_elements). This logic handles the remainder automatically. The last chunk simply has a smaller length.
For windows(n), the iterator tracks the start index. Each step advances the start index by one. The length is always n. The iterator yields items only while start + n <= total_length. If the slice is shorter than n, the iterator yields nothing.
This design keeps the overhead minimal. The compiler generates code that updates two integers and a pointer per iteration. There is no heap allocation. There is no cloning of the underlying data.
Realistic examples
Parsing fixed-width binary records
Binary protocols often use fixed-size records. chunks is the standard tool for processing these without unsafe code. You iterate over the buffer and interpret each chunk as a struct or a group of values.
/// Parse a buffer of fixed-size records.
/// Each record contains two little-endian u16 values.
fn parse_records(data: &[u8]) -> Vec<(u16, u16)> {
let mut results = Vec::new();
// Iterate over 4-byte chunks.
for chunk in data.chunks(4) {
// The last chunk may be incomplete.
// Check length before indexing to avoid panics.
if chunk.len() < 4 {
// Handle partial record or break.
break;
}
// Convert bytes to values using the chunk slice.
let a = u16::from_le_bytes([chunk[0], chunk[1]]);
let b = u16::from_le_bytes([chunk[2], chunk[3]]);
results.push((a, b));
}
results
}
The length check is essential. chunks guarantees the slice exists, but it does not guarantee the length matches n. If the input buffer has a trailing partial record, the loop handles it gracefully. Without the check, indexing chunk[3] would panic with an index out of bounds error.
Convention aside: When processing binary data, chunks is preferred over manual index arithmetic. The iterator encapsulates the bounds logic and makes the code harder to mess up.
Pattern detection with windows
Sliding windows excel at pattern matching. You can search for a sequence within a larger stream by comparing each window to the target pattern.
/// Find the first index where a pattern occurs in the data.
fn find_pattern(data: &[u8], pattern: &[u8]) -> Option<usize> {
// Create windows of the pattern length.
// Use position to find the first match.
data.windows(pattern.len())
.position(|w| w == pattern)
}
This approach is idiomatic and efficient. The position method returns the index of the first element that satisfies the predicate. The comparison w == pattern works because slices implement equality based on content. If the pattern is not found, position returns None.
If the pattern is longer than the data, windows returns an empty iterator. position on an empty iterator returns None. The function handles edge cases correctly without extra checks.
Mutable chunks and the window limitation
You can mutate data in place using chunks_mut. This method returns an iterator of &mut [T]. It allows you to modify each chunk without allocating.
/// Swap bytes in place for little-endian to big-endian conversion.
fn swap_bytes_in_place(data: &mut [u8]) {
// Iterate over mutable 2-byte chunks.
for chunk in data.chunks_mut(2) {
// Swap the two bytes if the chunk is full.
if chunk.len() == 2 {
chunk.swap(0, 1);
}
}
}
This is useful for in-place transformations. You can reorder bytes, normalize values, or apply filters directly to the buffer. The borrow checker ensures you cannot alias the chunks. Each mutable reference is disjoint.
There is no windows_mut. The borrow checker forbids overlapping mutable references. If windows_mut existed, you could modify the same element through multiple windows simultaneously, which violates Rust's aliasing rules. If you need to mutate based on a window, you must collect the indices first and then mutate, or use a different algorithm that avoids overlapping writes.
Counter-intuitive but true: the inability to have mutable windows is a feature, not a bug. It prevents data races and undefined behavior at compile time.
Pitfalls and compiler errors
The remainder trap
The most common mistake with chunks is assuming every chunk has length n. The last chunk is often shorter. Accessing an index that assumes full length causes a panic.
let data = [1, 2, 3];
for chunk in data.chunks(2) {
// This panics on the last chunk [3].
// Index 1 is out of bounds.
let _ = chunk[1];
}
The compiler cannot catch this at compile time because the length depends on runtime data. You must check chunk.len() or use chunks_exact if you require strict sizing.
Windows size constraints
Calling windows(0) panics. The window size must be at least one. Calling windows(n) where n > data.len() yields an empty iterator. This is safe but can be surprising if you expect a partial window.
let data = [1, 2];
// Yields nothing. No panic.
for w in data.windows(3) {
println!("{:?}", w);
}
If you need to handle short data differently, check the length before calling windows.
Borrowing conflicts
Both methods return iterators that hold a reference to the slice. You cannot mutate the slice while the iterator is active. The compiler enforces this with E0502 (cannot borrow as mutable because it is also borrowed as immutable).
let mut data = vec![1, 2, 3];
let chunks = data.chunks(2);
// This fails with E0502.
// data.push(4);
You must finish iterating or drop the iterator before mutating the data. If you need to modify the collection while processing, consider collecting the results first or using chunks_mut if the mutation is local to the chunks.
Decision matrix
Use chunks(n) when you need to process data in fixed-size blocks and the remainder is either valid data or needs special handling. Use chunks_exact(n) when the data length must be a multiple of n, and you want the iterator to stop cleanly without a remainder. Use windows(n) when you need to examine overlapping sequences, like pattern matching or moving averages. Use array_chunks::<N>() when you need fixed-size chunks that return arrays [T; N] instead of slices, avoiding length checks inside the loop.
chunks_exact is a variant of chunks that yields only full-sized chunks. It returns a remainder slice via the into_remainder method. This is useful when you want to process complete records and handle the leftover separately. array_chunks requires the chunk size to be a const generic parameter. It returns &[T; N], which allows array-specific operations and eliminates the need for length checks.
Where to go next
- How to Use drain and retain on Collections in Rust
- What is the difference between iter and into_iter
- What is the difference between Vec and VecDeque
Pick the method that matches your data shape. Forcing chunks where you need overlap makes the code fight itself. Trust the iterator to handle the pointers. Check the length of the last chunk.