Learning Rust · Part III · Chapter 04

Slices, a borrowed view

In Go you reach for a sub-slice without thinking, and the runtime tracks the bounds for you. Rust gives you the same window into a sequence, but it is a borrow with a length baked in, and the compiler ties its life to the thing it points at.

01

A window, not a copy

You have done this in every language you have used. You have an array or a list, and you want only part of it: the middle three elements, everything after the header, the first word of a line. In Go you write nums[1:4] and get a sub-slice; in Python you write text[0:4] and get a substring. You did not stop to think about whether that made a copy, because the language tracked the bounds for you and the answer rarely mattered. Rust gives you the very same move, with the very same range syntax, and it is worth slowing down for one chapter to see exactly what you get back. Read this, then press Run:

first_glimpse.rs
// A slice is a borrowed window into part of a sequence. You point at a
// run of elements that already live somewhere else, without copying them.
fn main() {
    let scores = [90, 72, 88, 65, 100];

    // &scores[1..4] borrows three elements: indices 1, 2, and 3.
    // The upper bound (4) is excluded, the same half-open range you know.
    let middle: &[i32] = &scores[1..4];

    println!("the whole array: {scores:?}");
    println!("a borrowed view:  {middle:?}");
    println!("the view has {} elements", middle.len());

    // A string slice is the same idea over the bytes of text.
    let line = String::from("host port");
    let host: &str = &line[0..4];
    println!("the first four bytes as text: {host:?}");
}

The array scores holds five numbers. &scores[1..4] hands back a slice: a borrowed view into three of those numbers, indices 1, 2, and 3. The type is written &[i32], a reference to a run of i32. No element was copied; the view points straight at the array’s own memory. The last two lines do the same thing to text: &line[0..4] is a string slice, written &str, a borrowed view into the first four bytes of the String. You have met &str informally before now; this chapter makes it precise. A &str is just a slice whose elements happen to be UTF-8 text.

A slice is a borrowed view

A slice does not own anything. It is a window onto a contiguous run of elements that some other value (an array, a Vec, a String) actually owns. You read or walk the elements through the window without taking them, and without copying them. Because it borrows, everything you learned about borrows in III.03 applies to it unchanged.

02

The four ranges, and the bound that bites

The bracket between the square brackets is a range, and there are four shapes of it. They are the same four you half-remember from Go and Python, written with two dots instead of a colon. Each one borrows a different window into the same sequence. Run this and watch the four views come back:

range_shorthands.rs
// The four range forms, on the same array. Each one borrows a different
// window; none of them copies an element.
fn main() {
    let week = ["mon", "tue", "wed", "thu", "fri"];

    let work = &week[0..2]; // a..b  : from a up to, but not including, b
    let from_wed = &week[2..]; // a..   : from a to the end
    let head = &week[..3]; // ..b   : from the start up to b
    let all = &week[..]; // ..    : the whole thing, as a slice

    println!("0..2  -> {work:?}");
    println!("2..   -> {from_wed:?}");
    println!("..3   -> {head:?}");
    println!("..    -> {all:?}");

    // A leftover Go habit: the upper bound is excluded. week[0..2] is two
    // elements, not three. week[..] is every element, and week.len() is 5.
    println!("week[0..2] holds {} elements", work.len());
}

Read them off one at a time. a..b is the full form: start at index a, stop just before index b. a.. drops the end, meaning from a to the end. ..b drops the start, meaning from the beginning up to b. And .. drops both, meaning the whole thing, turning an array or a Vec into a slice over all of its elements. That last one is the idiom you reach for when a function wants a &[T] and you have the whole collection in hand.

The upper bound is excluded

The end of a range is exclusive, exactly as in Go and Python. week[0..2] is two elements, indices 0 and 1, not three. This is the off-by-one that catches everyone the first week: a..b always holds b - a elements. If you want to include index b, write a..=b, the inclusive range, which you met for numbers in III.01 and which works here too.

One quiet detail before we move on. When you write &week[..] on an array, you get a slice over every element. When you write &week[2..], you get a slice over the tail. In both cases the & is doing real work: a range on its own is not a slice, it is an instruction for how to cut one, and the & is what produces the borrowed view.

03

A slice is a fat pointer

Here is the one piece of machinery worth knowing, because it explains everything else in the chapter. A plain reference, like &i32, is a single address: one machine word that says the value lives here. A slice reference is two words. The first is the address of the first element; the second is the length, the count of elements the view covers. That two-word bundle has a name: a fat pointer, a pointer that carries extra information alongside the address. Run this and read the sizes off the machine:

fat_pointer.rs
// A slice is a "fat pointer": two machine words, a pointer to the first
// element and a length. It is a borrow, so the borrow rules from III.03
// still apply to it.
use std::mem::size_of;

fn main() {
    // A plain reference is one word. A slice reference is two: pointer + length.
    println!("&i32  is {} bytes", size_of::<&i32>());
    println!("&[i32] is {} bytes", size_of::<&[i32]>());

    let data = vec![10, 20, 30, 40];

    // Two views into the same Vec at once. Both only read, so the borrow
    // checker is happy: any number of shared borrows may coexist.
    let front = &data[..2];
    let back = &data[2..];
    println!("front {front:?} and back {back:?} of {data:?}");

    // The view carries its own length, so it knows where it ends without
    // ever consulting the Vec again.
    println!("back starts at value {} and has len {}", back[0], back.len());
}

On a 64-bit machine a &i32 is 8 bytes, one word, and a &[i32] is 16 bytes, two words. The extra word is the length, and it is why a slice knows where it ends without ever asking the collection it came from. Once back exists, it carries its own pointer and its own count of two; it does not consult the Vec again to figure out its bounds. That self-contained length is also what makes a slice safe: every index is checked against the length the slice carries, so you cannot walk off the end into memory that is not yours.

Two words: where it starts and how long it is

A slice value is a pointer plus a length, packed into two machine words. The pointer says where the run of elements begins; the length says how many there are. That is the whole representation. It explains why .len() is free (the slice already knows), why bounds checks are cheap (compare an index to a number you already hold), and why a slice never owns or frees the elements it points at.

04

A slice is a borrow, so the borrow rules hold

Because a slice is a reference, it is governed by the borrow rules from III.03, with nothing added and nothing removed. A shared slice (the only kind we are using here) is a shared borrow of the data it points at, so while it is alive you may read the source as much as you like but you may not mutate it. The classic trap is growing a Vec while a slice into it is still in use, because growing a Vec can move its memory and leave the slice pointing at freed space. Rust refuses to let that happen. Run this, which respects the rule:

borrow_then_mutate.rs
// A slice is a borrow, so the rule from III.03 holds: you cannot change a
// Vec while a shared view into it is still alive. Move the view's last use
// above the change and it compiles.
fn main() {
    let mut nums = vec![1, 2, 3];

    // A shared borrow of the whole Vec, used right away.
    let view = &nums[..];
    println!("before: {view:?}");

    // The last use of `view` was the line above, so the shared borrow has
    // ended. Now the Vec is free to grow again.
    nums.push(4);
    println!("after:  {nums:?}");

    // If you uncomment the println below and move it under the push, the
    // borrow checker rejects it (error E0502): you would be reading `view`
    // after mutating the Vec it borrows from.
    // println!("stale view: {view:?}");
}

This compiles because of the same lesson from III.03: a borrow lives only until its last use, not until the end of the block. The last time view is read is the println! right above the push, so by the time push runs the shared borrow is already over and the Vec is free to grow. Uncomment the final line and move it below the push, and the borrow now stretches past the mutation: the compiler stops you with E0502, “cannot borrow numsas mutable because it is also borrowed as immutable”. The slice was your evidence that you still cared about the old contents, and Rust will not invalidate that view behind your back.

Why Go lets you do this and Rust does not

In Go, a sub-slice and an append can quietly share or stop sharing backing memory depending on capacity, and a stale slice is a real bug you debug at runtime. Rust moves that whole class of mistake to compile time: a slice is a tracked borrow, and you cannot mutate the source while the borrow is live. You lose the freedom to be careless and you gain the guarantee that a slice never points at memory that has moved out from under it.

05

Why &str and &[T] are the parameter types you want

Now the payoff, the reason slices earn their place in your everyday signatures. When a function only needs to read a sequence, you do not want it to demand ownership and you do not want it tied to one concrete container. Take a slice. A &[T] parameter accepts an array, a Vec, or a slice of either; a &str parameter accepts a String or a string literal. One signature, every caller. Run this:

one_param_many_owners.rs
// Take &str for read-only text and &[T] for read-only sequences. A &str
// parameter accepts a String or a literal; a &[T] parameter accepts an
// array, a Vec, or another slice. One signature, many kinds of caller.

// Reads text it does not own. Note the parameter is &str, not String.
fn shout(label: &str) -> String {
    label.to_uppercase()
}

// Reads a sequence it does not own. &[i32] accepts arrays and Vecs alike.
fn sum(values: &[i32]) -> i32 {
    let mut total = 0;
    for v in values {
        total += v;
    }
    total
}

fn main() {
    let owned = String::from("ready");
    println!("{}", shout(&owned)); // a String, borrowed as &str
    println!("{}", shout("set")); // a literal, already &str

    let array = [1, 2, 3];
    let vector = vec![10, 20, 30];
    println!("array sum:  {}", sum(&array)); // &[i32; 3] coerces to &[i32]
    println!("vector sum: {}", sum(&vector)); // &Vec<i32> coerces to &[i32]
    println!("middle sum: {}", sum(&vector[1..])); // a slice of the Vec
}

Look at what each call site got to pass. shout takes &str, so it accepts a borrowed String (&owned) and a literal ("set", already a &str) with the same signature. sum takes &[i32], so &array (an array), &vector (a Vec), and &vector[1..] (a slice of that Vec) all flow in. The compiler quietly coerces a &[i32; 3] and a &Vec<i32> down to a &[i32] at the call site; you do not write a conversion. The function never learns which container it was handed, and it never needed to.

Borrow the view, not the container

A function that takes String can be called only with a String, and it takes ownership to boot. A function that takes &str reads any text without owning it, whatever it is stored in. The same goes for Vec<T> versus &[T]. The rule of thumb is simple: if the function only reads, ask for the slice, not the owner. You will see &str and &[T] in nearly every read-only signature in real Rust for exactly this reason.

06

The first-word problem

Here is the example every Rust course reaches for, because it makes the point so cleanly. You are handed a line of text and you want its first word. In a language with cheap string copies you would allocate a new string and return it. In Rust you can do better: return a viewinto the caller’s own text, no allocation at all. The return type is &str, a borrowed window onto a slice of the input. Run it:

first_word.rs
// The classic: return the first word of a line without copying it. The
// answer is a slice, a borrowed view into the caller's own text.
fn first_word(line: &str) -> &str {
    // Walk the bytes; the first space is where word one ends.
    let bytes = line.as_bytes();
    for (i, &byte) in bytes.iter().enumerate() {
        if byte == b' ' {
            return &line[..i]; // a view of bytes 0..i, no allocation
        }
    }
    line // no space found: the whole line is one word
}

fn main() {
    let command = String::from("deploy api prod");
    let word = first_word(&command);
    println!("the command is {word:?}");

    // It works the same on a literal, because that is already a &str.
    println!("single word: {:?}", first_word("status"));

    // The returned slice borrows from `command`, so it stays valid only as
    // long as `command` does. III.05 gives that tie an explicit name.
    println!("the source is still intact: {command:?}");
}

The function walks the bytes until it meets a space, then returns &line[..i], a slice of everything before that space. No new string is built; the result borrows directly from the argument. That is why command is still whole on the last line: nothing was taken from it, only viewed. And because first_word takes &str, it works on a borrowed String and on a bare literal alike, the lesson from the previous section paying off immediately.

There is a subtlety hiding in that return type, and it is the right place to plant a flag. The slice first_word returns is borrowed from the argument, so it is only valid for as long as that argument lives. Drop the String the view points into and the view would dangle, which Rust does not allow. The compiler keeps the returned slice and its source tied together automatically here. The exact mechanism that names and enforces that tie is called a lifetime, and it is the whole subject of III.05, the very next chapter. For now, trust that the borrow checker will not let the view outlive what it views.

A slice is not a null-terminated pointer

If you come from C, do not picture &str as a char* that runs until a zero byte. A string slice is a pointer and a length: it knows precisely where the word ends because first_word told it (..i), not because it scans for a terminator. That is why a slice can name the middle of a buffer with no copy and no sentinel byte: the length travels with it.

07

Matching on shape: slice patterns

You already met match as the tool that takes a value apart by its shape. It works on slices too, and the patterns read almost like a picture of the data. You can match the empty slice, a slice of one, a head-and-tail split, an exact pair, or the two ends with the middle ignored. Run this:

slice_patterns.rs
// match can take a slice apart by shape. [first, rest @ ..] binds the head
// and gathers the tail; [a, b] matches a slice of exactly two; [] matches
// the empty one. The `..` is a "rest" pattern, not a range here.
fn describe(items: &[&str]) -> String {
    match items {
        [] => String::from("nothing at all"),
        [only] => format!("just {only}"),
        [first, rest @ ..] => format!("{first}, then {} more", rest.len()),
    }
}

fn main() {
    println!("{}", describe(&[]));
    println!("{}", describe(&["solo"]));
    println!("{}", describe(&["a", "b", "c", "d"]));

    // You can also pin an exact length. This arm only matches a pair.
    let pair = [3, 9];
    match pair {
        [low, high] => println!("a pair: {low} and {high}"),
    }

    // And match the ends while ignoring the middle with `..`. On a slice
    // (&[i32]) the length is not known up front, so this arm is one case
    // among others and the if let is honest.
    let row: &[i32] = &[1, 2, 3, 4, 5];
    if let [head, .., tail] = row {
        println!("ends are {head} and {tail}");
    }
}

Read the arms in describe top to bottom. [] matches an empty slice. [only] matches a slice with exactly one element and binds it to only. [first, rest @ ..] is the interesting one: it binds the first element to first and then says rest @ .., meaning bind the rest of the elements, however many, to rest. The .. here is not a range; in a pattern it is the rest pattern, standing for “zero or more elements I am gathering or ignoring”. The @means “bind the part matched by the pattern on my right to the name on my left”. So rest @ ..is “name the leftovers rest”.

The remaining two examples show the other moves. Matching [low, high] on a fixed array of two pins an exact length and names both elements. And [head, .., tail] binds the first and last and uses a bare ..to skip everything between. Slice patterns turn “check the length, then index” into a single readable shape, and because match is exhaustive, the compiler makes you account for the cases you did not name.

Two dots, two jobs

The .. token does double duty, and context tells them apart. Inside [ ] when you are slicing, like &v[2..], it is a range. Inside a match pattern, like [first, rest @ ..], it is the rest pattern, standing for the remaining elements. Same two characters, but one cuts a slice and the other matches the tail of one.

08

The UTF-8 caveat: slicing text by bytes

String slices carry one rule that array slices do not, and it trips people who assume a string is an array of characters. A &str is stored as UTF-8: a sequence of bytes where an ASCII character takes one byte but an accented or non-Latin character takes two, three, or four. The range you give &text[a..b] counts bytes, not characters. Cut through the middle of a multi-byte character and the slice would be invalid UTF-8, so Rust panics rather than hand you a broken string. Run this:

char_boundaries.rs
// A &str is UTF-8 bytes, and a string slice must land on a char boundary.
// Slicing through the middle of a multi-byte character panics at runtime.
fn main() {
    // "café" is five bytes: c, a, f are one byte each, and é is two (0xC3 0xA9).
    let word = "café";
    println!("{word:?} is {} bytes but {} chars", word.len(), word.chars().count());

    // Bytes 0..3 stop on a boundary (just before é), so this is fine.
    let head = &word[0..3];
    println!("a valid slice: {head:?}");

    // get returns Option, so a bad boundary is None instead of a panic. Byte
    // 4 falls inside é, so this asks for an invalid slice and gets None.
    println!("word.get(0..4) = {:?}", word.get(0..4));
    println!("word.get(0..5) = {:?}", word.get(0..5));

    // Indexing with &word[0..4] would panic here ("byte index 4 is not a char
    // boundary"). Prefer char-aware tools when text may be non-ASCII.
    for (i, ch) in word.char_indices() {
        println!("char {ch:?} starts at byte {i}");
    }
}

The word "café" looks like four characters and it is, but .len() reports five bytes, because the é is two bytes. Slicing 0..3 lands cleanly just before the é, so it is valid. Slicing 0..4 would land in the middle of the é, which is why the safe get(0..4) returns None instead of a string, while get(0..5) reaches the next boundary and returns Some("café"). The position where a character begins is called a char boundary, and a string slice must start and end on one.

Byte index is not character index

Indexing a &strwith a range that splits a character panics at runtime with “byte index N is not a char boundary”. There is no way to index a &str by a single byte at all (text[0] does not compile), precisely so you cannot pull out half a character by accident. When your text might be non-ASCII, slice on boundaries you trust, reach for .get(a..b) to get an Option instead of a panic, or walk characters with .chars() and .char_indices(), which always land on real boundaries.

09

Read it again

Scroll back to the first program and read it once more. Every piece has a name now. &scores[1..4] is a slice, a two-word fat pointer holding an address and a length, borrowing three elements without copying them. &line[0..4] is a string slice, the same idea over UTF-8 bytes, valid because byte 4 sits on a char boundary. Both are borrows, so the III.03 rules govern them; both carry their own length, so they are safe to index and free to measure. And when a function only reads a sequence, you now know to ask for the view, &[T] or &str, not the container.

The shape, in one breath

A slice is a borrowed, length-carrying window into a contiguous run of elements you do not own. &[T] views any sequence, &str views UTF-8 text, the ranges a..b, a.., ..b, and .. say which part, and the borrow checker keeps the view from outliving its source.

One thread runs ahead of this chapter and gets its own home. Every slice you returned here borrowed from something, and the compiler tied the view’s life to its source for you, silently. The next chapter, III.05, gives that tie an explicit name, the lifetime, and shows you when you have to write it down yourself and what it means when you do.

The whole chapter in six lines

  1. A slice is a borrowed view into a contiguous run of elements: &[T] for sequences, &str for UTF-8 text. It copies nothing.
  2. The ranges say which part: a..b (end excluded), a.., ..b, and .. for the whole thing.
  3. A slice is a fat pointer, a pointer plus a length, so it knows where it ends and every index is bounds-checked.
  4. A slice is a borrow, so the III.03 rules hold: you cannot mutate the source while a shared view into it is still alive.
  5. Prefer &str and &[T] as parameters for read-only data: they accept literals, arrays, Vecs, Strings, and other slices.
  6. A string slice must fall on a char boundary; slicing through a multi-byte character panics, so use .get or .char_indices when text may be non-ASCII.
End-of-chapter assessmentCheck your understanding20 questions. Your first attempt is recorded on your report card.Take it →