Ranges and Slices in Rust Programming Language: Working with Sequences and Subsets of Data

Ranges and Slice in Rust

Ranges and Slice in Rust

Slices were the first Rust feature that made me stop and think “wait, this is actually a clever solution to a problem I didn’t realize I had.” Coming from C++, I was used to passing around pointers and lengths separately, hoping I hadn’t mismatched them. Rust bundles a pointer and a length into a single, borrow-checked type, and suddenly a whole category of bugs I used to accept as normal just… stopped happening.

In this article I’ll cover ranges (0..5, 0..=5, etc.) and slices (&[T], &str) together, because in practice they’re used constantly side by side — ranges are how you index into slices, and slices are how you work with borrowed, contiguous sequences of data.

Ranges: Expressing a Sequence of Values

A range in Rust is a value produced by the .. and ..= syntax. It’s not magic — it’s just a struct.

fn main() {
    let r = 0..5; // Range<i32>
    println!("{:?}", r);

    for i in 0..5 {
        print!("{} ", i);
    }
    println!();

    for i in 0..=5 {
        print!("{} ", i);
    }
    println!();
}

Output:

0..5
0 1 2 3 4
0 1 2 3 4 5

Both types implement Iterator, which is why you can for-loop over them directly. This ties directly into how ranges are used for slicing: Range is not just for loops, it’s also the type accepted by indexing operations.

fn main() {
    let v = vec![10, 20, 30, 40, 50];

    println!("{:?}", &v[1..3]);   // exclusive: indices 1, 2
    println!("{:?}", &v[1..=3]);  // inclusive: indices 1, 2, 3
    println!("{:?}", &v[..2]);    // from start
    println!("{:?}", &v[2..]);    // to end
    println!("{:?}", &v[..]);     // whole thing
}

Output:

[20, 30]
[20, 30, 40]
[10, 20]
[30, 40, 50]
[10, 20, 30, 40, 50]

Slices: A Borrowed View Into a Sequence

A slice, written &[T], is a view into a contiguous block of memory — it does not own the data. Internally, a slice reference is a fat pointer: it stores both a pointer to the first element and a length.

fn main() {
    let arr = [1, 2, 3, 4, 5];
    let slice: &[i32] = &arr[1..4];

    println!("slice: {:?}", slice);
    println!("length: {}", slice.len());
}

Output:

slice: [2, 3, 4]
length: 3

This is why passing a slice to a function is cheap regardless of the size of the underlying data — you’re copying a pointer and a length (16 bytes on a 64-bit system), not the elements themselves.

fn sum_slice(s: &[i32]) -> i32 {
    s.iter().sum()
}

fn main() {
    let v = vec![1, 2, 3, 4, 5];
    let arr = [10, 20, 30];

    // Works with Vec, arrays, and slices of slices - all coerce to &[i32]
    println!("{}", sum_slice(&v));
    println!("{}", sum_slice(&arr));
    println!("{}", sum_slice(&v[1..3]));
}

Output:

15
60
5

This is one of the most practical lessons I learned: write functions that take &[T] instead of &Vec<T>. A &Vec<T> only accepts vectors, but &[T] accepts vectors, arrays, and slices of either — via automatic deref coercion. It’s a strictly more flexible signature with no downside.

String Slices: &str

Strings get their own dedicated slice type, &str, which is a view into UTF-8 encoded bytes. Every string literal in Rust is actually a &'static str.

fn main() {
    let greeting = String::from("Hello, Rust world!");

    let hello = &greeting[0..5];
    let world = &greeting[7..12];

    println!("{}", hello);
    println!("{}", world);
}

Output:

Hello
Rust

There’s a subtlety here that bit me early on: &str slicing uses byte indices, not character indices. Because Rust strings are UTF-8, slicing at a byte boundary that falls in the middle of a multi-byte character causes a runtime panic.

fn main() {
    let s = "héllo"; // é is 2 bytes in UTF-8
    let bad = &s[0..2]; // panics: not a char boundary
    println!("{}", bad);
}

Output:

thread 'main' panicked at src/main.rs:3:16:
byte index 2 is not a char boundary; it is inside 'é' (bytes 1..3) of `héllo`

For safe, character-aware slicing on non-ASCII text, I use .chars() combined with .take()/.skip(), or the char_indices() method to find valid boundaries first.

Ownership and Borrowing With Slices

Because a slice is a borrow, not an owner, the borrow checker enforces the same rules that apply to any reference: you can have many immutable slices, or exactly one mutable slice, but not both at once.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let slice1 = &v[0..2];
    let slice2 = &v[2..4];
    println!("{:?} {:?}", slice1, slice2); // fine: two immutable borrows

    let m = &mut v[0..2];
    m[0] = 100;
    println!("{:?}", v);
}

Output:

[1, 2] [3, 4]
[100, 2, 3, 4, 5]

This next example is the classic “why won’t this compile” moment for beginners:

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4); // error: cannot borrow `v` as mutable while borrowed as immutable
    println!("{}", first);
}

The compiler rejects this because v.push(4) might reallocate the vector’s backing buffer, which would leave first pointing at freed memory — exactly the kind of dangling-pointer bug that Rust’s borrow checker exists to prevent. This is memory safety enforced entirely at compile time, with zero runtime cost.

split_at, chunks, and windows: Practical Slice Methods

fn main() {
    let data = [1, 2, 3, 4, 5, 6];

    // split_at: divide into two slices at an index
    let (left, right) = data.split_at(3);
    println!("left: {:?}, right: {:?}", left, right);

    // chunks: fixed-size non-overlapping groups
    for chunk in data.chunks(2) {
        println!("chunk: {:?}", chunk);
    }

    // windows: fixed-size overlapping groups
    for window in data.windows(3) {
        println!("window: {:?}", window);
    }
}

Output:

left: [1, 2, 3], right: [4, 5, 6]
chunk: [1, 2]
chunk: [3, 4]
chunk: [5, 6]
window: [1, 2, 3]
window: [2, 3, 4]
window: [3, 4, 5]
window: [4, 5, 6]

I use chunks constantly for batch-processing data (like sending records to an API in groups of 50) and windows for anything involving comparing neighboring elements, like detecting consecutive increases in a series of numbers.

Real-World Example: Parsing a CSV-Style Line

fn parse_row(line: &str) -> Vec<&str> {
    line.split(',').map(|field| field.trim()).collect()
}

fn main() {
    let row = "Ayesha, 29, Lahore";
    let fields = parse_row(row);

    println!("{:?}", fields);

    if let [name, age, city] = fields.as_slice() {
        println!("{} is {} years old and lives in {}", name, age, city);
    }
}

Output:

["Ayesha", "29", "Lahore"]
Ayesha is 29 years old and lives in Lahore

That if let [name, age, city] = fields.as_slice() line is slice pattern matching — one of my favorite lesser-known Rust features. It destructures a slice by shape, and only matches if the slice has exactly three elements, which makes malformed input fail safely instead of panicking on an out-of-bounds index.

Performance Notes

Slices carry zero runtime overhead beyond the pointer-and-length pair itself. Iterating a slice compiles to the same tight loop as iterating a raw array in C. Bounds checking does happen on indexing (v[i]) to guarantee memory safety, but the compiler frequently eliminates redundant checks when it can prove an index is in range (for example, inside a for loop over 0..v.len()). When it can’t prove that, and you’ve already validated the bounds yourself, .get_unchecked() exists for the rare case where you need to skip the check — though I’ve needed it maybe twice in years of writing Rust, and only in tight numerical loops.

Best Practices

Common Mistakes

  1. Slicing a &str at a non-char-boundary byte index, causing a panic on non-ASCII input.
  2. Holding an immutable slice across a mutation of the original collection, which the borrow checker will (correctly) reject.
  3. Using &Vec<T> in function signatures instead of the more general &[T].
  4. Forgetting ranges are half-open by default (0..5 excludes 5), leading to off-by-one confusion — especially for anyone coming from a language with inclusive ranges by default.

FAQs and Troubleshooting

Q: What’s the difference between an array, a Vec, and a slice? A: An array ([T; N]) has a fixed, compile-time-known size and lives on the stack (unless boxed). A Vec<T> is a heap-allocated, growable owner of its data. A slice (&[T]) is a borrowed, non-owning view into either one.

Q: Why did my slice indexing panic with “index out of bounds”? A: Your range extends beyond the length of the underlying collection. Double-check off-by-one errors, especially with exclusive (..) vs inclusive (..=) ranges.

Q: Can I mutate through a slice? A: Yes, using &mut [T], obtained via .as_mut_slice(), &mut v[..], or similar. Ownership rules still apply — one mutable borrow at a time.

Q: How do I safely slice a string with non-ASCII characters? A: Use .chars() combined with .take(n) and .collect::<String>(), or find a valid boundary with .char_indices() before slicing by byte range.

Summary

Ranges and slices might look like small syntactic conveniences, but together they express one of Rust’s core ideas: you can work with a subset or sequence of data without copying it and without giving up memory safety. Ranges describe a sequence of indices or values; slices are the borrowed, bounds-checked windows into actual data that ranges are so often used to create. Once I started writing functions that accepted &[T] and &str by default instead of owned types, my Rust code became both faster and more flexible — which is a rare combination to get for free.

References

Exit mobile version