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

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
  • 0..5 is a Range<i32> — exclusive of the end (0 through 4).
  • 0..=5 is a RangeInclusive<i32> — inclusive of the end (0 through 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

  • Accept &[T] in function signatures, not &Vec<T>, for maximum flexibility.
  • Use .get(i) instead of v[i] when the index isn’t guaranteed to be valid — it returns Option<&T> instead of panicking.
  • Be careful with &str byte-slicing on non-ASCII text; prefer .chars(), .char_indices(), or crates like unicode-segmentation for correctness.
  • Use slice patterns (if let [a, b, c] = ...) for destructuring fixed-size or variable-size data safely.

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

Total
0
Shares

Leave a Reply

Previous Post
Using Changeable Strings in Rust

Using Changeable Strings in Rust Programming Language: String, &str, and Mutable String Operations Guide

Next Post
Using Iterators in Rust

Using Iterators in Rust Programming Language: Lazy Evaluation, Adapters, and Consumers Explained

Related Posts