Skip to content
beginner Phase 3 · Borrowing, References, and the Borrow Checker

Slices as Fat References

Borrow contiguous ranges of arrays and strings with &[T] and &str.

40m
0 problems
Topic Progress 0%

&[T]: A Pointer and a Length, Borrowing a Range

A slice borrows a contiguous range of a collection without owning or copying it. Type &[T] is a reference to T elements — but unlike a plain &T, it is a fat pointer: two machine words, a pointer to the first element and a length:

&[T]  (16 bytes on 64-bit)              underlying array/Vec
┌────────────────────────┐        ┌───┬───┬───┬───┬───┐
│ ptr ───────────────────────────▶│ 3 │ 1 │ 4 │ 1 │ 5 │
│ len = 3               │        └───┴───┴───┴───┴───┘
└────────────────────────┘          ▲
                          slices v[1..4] start here, len 3

Forming a slice uses range syntax, inclusive-start, exclusive-end (the half-open convention from C and most languages):

let v = vec![3, 1, 4, 1, 5];
let mid: &[i32] = &v[1..4];    // elements 1,2,3 → [1, 4, 1]
let first = &v[..1];           // from the start
let rest  = &v[3..];           // to the end
let all   = &v[..];            // the whole thing (len = 5)
let none  = &v[2..2];          // empty slice at offset 2 — legal

The borrow checker connects slices to their source: while mid is alive, the Vec cannot be mutated or moved — a slice is a borrow of the collection, subject to all the rules from Shared References. That is exactly the safety property you want: no possible way for the sliced range to be invalidated underneath you.

Slices pass to functions as slice parameters, which is where the design payoff shows:

fn total(items: &[i32]) -> i32 { items.iter().sum() }   // &[i32], not &Vec<i32>

let v = vec![1, 2, 3];
let arr = [1, 2, 3];
total(&v);          // from a Vec
let s = &arr[..];   // from an array

&[i32] accepts arrays, Vecs, and other slices uniformly — &Vec<i32> would exclude arrays and other containers, and clippy even lints against &Vec<T> parameters (clippy::ptr_arg). For mutation there is the counterpart &mut [T] — a fat pointer with exclusive access, used by sorting, in-place fills, and anything that rearranges elements through a borrowed range.

Reading a Copy element out of a slice is allowed by indexing — it duplicates rather than moves:

let first = v[0];       // i32 is Copy — fine even while v is borrowed elsewhere

But moving a non-Copy element out by index is not (E0507 — the same collection-integrity rule from partial moves): you borrow it (&v[0]), copy it (.clone()), or consume the collection (into_iter()). The slice vocabulary — fat pointer, half-open ranges, borrow-tracked lifetime — is the foundation the string-slice story is built on.

The checked forms extend to ranges and to the read-only accessors that build on slices:

let v = vec![3, 1, 4, 1, 5];
assert_eq!(v.get(1..4), Some(&[1, 4, 1][..]));   // Option<&[i32]> — no panic
assert_eq!(v.get(9..12), None);                  // out of range → None

let (lo, hi) = v.split_at(3);                    // (&[3,1,4], &[1,5])
for w in v.windows(2) { /* [3,1] [1,4] [4,1] [1,5] */ }
for c in v.chunks(2) { /* [3,1] [4,1] [5] */ }

split_at, windows, chunks, and get(range) are all pure slice operations — they borrow, never allocate, and hand back more slices. This is the practical payoff of the fat pointer: because a slice knows its own length, every adapter on top of it needs nothing but (ptr, len) and can work identically over a Vec, a stack array, or a buffer you received from elsewhere.

Two edges to know when slicing values that are not yours. Slicing an array temporary borrows into a value that dies at the semicolon — let head = &make_array()[..2]; is E0716, temporary value dropped while borrowed — so bind the array first. And slicing through a reference (&&[i32]) still works because auto-deref finds the slice underneath; what you cannot do is form a slice that outlives the collection: let bad: &[i32] = local_vec(...); gets the same lifetime rejection you have already seen. The rule never changes — a slice is a borrow, and every borrow needs a referent that outlives it.

&str Is a Slice of UTF-8 Bytes — and Char Boundaries Are Law

str is the unsized string type; &str is a borrowed string slice — a pointer plus byte length into UTF-8 data stored somewhere else. A string literal "hello" is &'static str pointing into the binary; &some_string[..] points into a String's heap buffer. Either way, &str owns nothing:

let s = String::from("hello world");
let word: &str = &s[0..5];       // borrows bytes 0..5 → "hello"
println!("{}", word);            // s cannot be mutated while `word` is alive

Slicing a String uses the same range syntax, but the index unit is bytes, and UTF-8 gives them meaning: every character occupies 1–4 bytes, and a slice may only end on a char boundary — the first byte of some character. Cross one and Rust panics at runtime:

let s = String::from("héllo");   // é = 2 bytes: h(1) é(2) l l o → 6 bytes total
let bad = &s[..2];               // panics: byte index 2 is not a char boundary
let ok  = &s[1..3];              // é exactly: its two bytes, boundary-aligned

This is safety, not a quirk: returning a slice that split a multi-byte character would create invalid UTF-8 behind a type that promises validity. Rust refuses at the boundary instead of corrupting your data. The same law explains why "héllo".len() is 6 (bytes) while .chars().count() is 5 (characters) — length is byte length, so never use len() as a character count for text beyond ASCII.

The canonical exercise every Rust programmer writes once — and a favorite interview request — is finding the first word by returning a slice:

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &b) in bytes.iter().enumerate() {
        if b == b' ' {
            return &s[..i];       // slice from the start to the first space
        }
    }
    &s[..]                        // no space: the whole string is one word
}

fn main() {
    let mut text = String::from("hello world");
    let word = first_word(&text);
    // text.clear();              // error[E0502]: cannot borrow `text` as mutable
    println!("{}", word);        //   because it is also borrowed as immutable
    text.clear();                 // ok AFTER word's last use (NLL)
}

The returned &str ties the result to the input's lifetime — word stays valid only while text lives, and the borrow checker blocks any mutation that could invalidate it. This is the deep reason slices beat index-pairs: a (start, end) tuple would happily go stale when the string is modified, while the borrow carries the correctness proof for free.

So how do you slice safely when the index comes from input? Use the same predicate the operator checks before panicking:

fn cut(s: &str, byte: usize) -> Option<&str> {
    if s.is_char_boundary(byte) { Some(&s[..byte]) } else { None }
}

is_char_boundary is the guard for dynamic indices — and once you can prove the boundary, slicing hands you free, zero-copy text operations: trim, split_whitespace, split(','), strip_prefix, and &s[..i] all return &str views into the original buffer with no allocation. String::truncate enforces the same law, panicking on a non-boundary index just like slicing does.

Iteration belongs to the same family, and is where most text code should live:

for (idx, c) in s.char_indices() { /* idx = byte offset, c = char */ }
assert!(s.eq_ignore_ascii_case("FERRIS"));

char_indices pairs each character with its byte position — the correct way to find, cut, and reassemble text without ever constructing an index that could split a sequence. When input is not valid UTF-8 to begin with, the boundary question is answered upstream instead: std::str::from_utf8(&bytes) returns Result<&str, Utf8Error> whose valid_up_to() tells you where the good prefix ends, and String::from_utf8_lossy substitutes U+FFFD for invalid bytes rather than failing. Choose deliberately: validate at the boundary, then slice, and every downstream &str is honest without re-checking anything.

Bounds Are Checked at Runtime — Every Index, Every Time

Rust has no out-of-bounds accesses — every index and range is bounds-checked, and a violation is a panic with the numbers spelled out:

let v = vec![1, 2, 3];
let x = v[5];       // panic: 'range end index 6 out of range for slice of length 3'
                    //   (indexing v[5] is sugar for the range 5..6)
let y = &v[1..9];   // panic: 'range end index 9 out of range for slice of length 3'

Two facts professionals internalize. First, the check cannot be turned off — there is no unsafe-by-default mode; opting out requires explicit unsafe and get_unchecked, which is where undefined behavior lives. Second, checks are cheap (a compare against the length on the bounds you actually touch) and amortized away in release builds' inlining — which is why the Rust answer to C's "bounds checks are too slow" is "write safe code, then measure."

Because panicking APIs are wrong for untrusted indices, slices ship a checked accessor that returns an option:

let v = vec![1, 2, 3];
match v.get(5) {                // Option<&i32> — no panic
    Some(x) => println!("{}", x),
    None    => println!("out of range"),
}
let third: Option<&i32> = v.get(2);

The design rule that follows: v[i] for indices your code's logic guarantees; v.get(i) for indices from input, parsing, arithmetic, or iteration counts. API functions that take a position should return Option rather than gamble on caller arithmetic — that is how you keep "panics only on bugs" true.

Range forms to have in your fingers:

Range Meaning
&v[a..b] elements a through b-1
&v[..b] from start to b
&v[a..] from a to end
&v[..] everything
&v[a..a] empty slice at a

Any of these with a > b panics (slice index starts at X but ends at Y), and any endpoint over the length panics with the out-of-range message. One conversion worth knowing: an array can be viewed as a slice directly — let s: &[i32] = &arr[..]; — so generic slice code handles arrays, vectors, and other containers' buffers with one implementation. Between fat pointers, char boundaries, and bounds-checked ranges, slices are the piece of Rust that most directly answers "how do you get C's performance without C's memory bugs" — same layout, proven invariants, checks only where the proof runs out.

Slices are also where in-place algorithms live, because &mut [T] gives exclusive access to a range without owning it:

fn normalize(values: &mut [f64]) {
    let total: f64 = values.iter().sum();
    for v in values.iter_mut() { *v /= total; }   // write through the borrow
}

let mut data = vec![1.0, 3.0, 6.0];
normalize(&mut data);          // &Vec<f64> derefs to &mut [f64] — no copy of the vector
data.sort();                   // sort, reverse, swap — all slice methods

Note what the signature rules out: the function cannot push, clear, or replace the vector — only rearrange and rewrite the elements inside it. That is the API contract you want for numeric and sorting code, and it costs nothing: the callee receives two words and a promise.

Ranges as first-class values round out the vocabulary. A function can take the range itself and let the caller decide the edges:

fn middle(s: &str, r: std::ops::Range<usize>) -> &str { &s[r] }

let word = middle(&line, 6..11);        // explicit at the call site
let tail = &v[v.len().saturating_sub(3)..];   // defensive end, never panics

Range<usize> is the same half-open syntax you write inline, just named — and combining it with saturating_sub or get is how you keep untrusted arithmetic from producing an inverted range (which panics with slice index starts at X but ends at Y). Everything in this topic composes into one habit: borrow ranges instead of copying them, prove boundaries before slicing them, and choose checked access whenever the index is computed rather than known.

Quiz

1. What is inside a `&[i32]` value, and why is it called a fat pointer?

Question 1 options

2. With `let s = String::from("");` where é occupies bytes 1–2, what does `&s[..2]` produce?

Question 2 options

3. In `fn first_word(s: &str) -> &str`, why can the compiler return a slice of the input, and what does the caller gain?

Question 3 options

4. Indices come from user input and might be wrong. Which access is appropriate, and why?

Question 4 options

Flashcards

Question

What is the layout of &[T], and how do range forms map to it?

Answer

A fat pointer: (ptr to first element, len) — two words. &v[a..b] → start at a, len b-a (half-open); &v[..b], &v[a..], &v[..] abbreviate; &v[a..a] is a legal empty slice. The borrow checker ties the slice's existence to the source collection — no mutation/move while it lives.

Question

str vs String vs &str — the slice story for text.

Answer

str is the unsized UTF-8 string type (never used alone); &str is a borrowed slice — (ptr, byte-len) into UTF-8 stored elsewhere (binary for literals → &'static str, or a String's heap buffer). String is the owned growable buffer. &str = 'I read text from somewhere; nothing to free.'

Question

What is a char boundary, and what happens when you slice mid-character?

Answer

The first byte of a UTF-8 encoded character (ASCII: every byte; é: 2-byte sequence; emoji: 4). String slicing indexes BYTES and panics at runtime if a range edge splits a character — 'byte index N is not a char boundary' — because a slice must preserve valid UTF-8. Guard: check is_char_boundary(i) before slicing dynamic indices.

Question

&[T] vs &Vec<T> vs &[T; N] in a parameter — which and why?

Answer

&[T] — the generic choice: accepts Vec, arrays (&arr[..] or &arr), and other slices; zero-cost, and clippy lints &Vec<T> (ptr_arg). &[T; N] restricts to one fixed size. &Vec<T) unnecessarily demands a Vec and reveals the representation. Mutation variant: &mut [T].

Question

Bounds checking: what happens on violation, can it be disabled, and what is the checked alternative?

Answer

Every index/range is checked at runtime; violation panics with exact numbers ('range end index 9 out of range for slice of length 3'). It cannot be disabled in safe code — only explicit unsafe get_unchecked opts out (and takes on UB). The graceful alternative is v.get(i) -> Option<&T> for untrusted indices.

Revision Notes

Key Takeaways

  • 1. A slice is a fat pointer — (ptr, len) — that borrows a range; the source cannot move or mutate while any slice borrow is live
  • 2. &str is the string slice: byte-indexed UTF-8 whose boundaries must align to characters, enforced by runtime panics rather than silent corruption
  • 3. String::len counts bytes and chars().count() counts characters — conflating them is the most common text bug in Rust code
  • 4. Every index is bounds-checked; v[i] panics loudly with indices, v.get(i) returns Option — choose by whether the index is trusted
  • 5. &[T] and &str are the universal parameter types for reading collections and text — they accept every representation via coercion and deref

Interview Tips

  • • "What is a fat pointer?" — (ptr, len) for slices (and ptr+vtable for trait objects) vs one word for &T; explain that the slice carries its own extent so it never consults the parent Vec
  • • "Why does slicing a String panic mid-character?" — indices are byte offsets and slices must preserve valid UTF-8; the compiler can't know input lengths statically so the check runs at runtime — safety over UB, then mention is_char_boundary for guarding
  • • "first_word returning &str — what does the signature mean?" — elided lifetimes tie output to input (<'a>(&'a str) -> &'a str); zero-copy and the borrow checker prevents invalidation (E0502 on clear()) — the slice-as-proof point
  • • "&[T] vs &Vec<T> as a parameter?" — &[T] accepts Vec, arrays, and slices (Deref), is representation-agnostic, and clippy lints &Vec<T> (ptr_arg); shows API-design awareness, not just syntax

Cheat Sheet

Slices as Fat References

&[T] = (ptr, len) — 16 bytes on 64-bit; borrows a contiguous range; source frozen while the slice lives.

let v = vec![3, 1, 4, 1, 5];
let mid = &v[1..4];   // [1, 4, 1]
let head = &v[..1]; let tail = &v[3..]; let all = &v[..]; let none = &v[2..2];

&str for text — same fat-pointer shape over UTF-8 bytes:

let s = String::from("hello world");
let word = &s[0..5];                 // "hello" — borrows s
fn first_word(s: &str) -> &str { ... } // lifetime tied to input; zero copy

Char boundaries (runtime law): indices are BYTES; ranges must start/end on a char's first byte or panic — &"héllo"[..2] → 'byte index 2 is not a char boundary' (é spans bytes 1–2). Hence len() ≠ chars().count().

Bounds: every index checked; v[5] on len-3 → panic with exact numbers. Untrusted index → v.get(i) -> Option<&T>.

API defaults:

| Parameter | Accepts | Note |
|---|---|
| &[T] | Vec, arrays, slices | the right generic choice (clippy: ptr_arg on &Vec) |
| &str | literals, &String, slices | the right text choice |
| &mut [T] / &mut str | in-place mutation | exclusive borrow |

Continue Learning

Before You Start

Next Topics

Related Reading