Skip to content

← Ownership II: Borrowing and the Borrow Checker step 9 of 24

Easy Primitives

Slices are borrows

pub fn first_word(s: &str) -> &str
pub fn last_word(s: &str) -> &str

Both return a slice of the input, not a new string.

The semantics are pinned to the ASCII space character ' ', exactly:

  • first_word returns everything before the first space. If there is no space, the whole input.
  • last_word returns everything after the last space. If there is no space, the whole input.
"hello world" -> "hello", "world"
""            -> "",      ""
"  lead"      -> "",      "lead"      <- the first space is at index 0
"trail  "     -> "trail", ""          <- the last space is the final byte
"single"      -> "single","single"
"a b c"       -> "a",     "c"

Read the third and fourth lines carefully. The starter uses split_whitespace(), which skips leading and trailing whitespace and therefore gets both of those cases wrong. That is deliberate: half of all submissions to a problem like this fail on ambiguous whitespace rules rather than on the actual subject, so the rule here is stated and tested rather than implied. split_whitespace() is a fine function; it just does not compute what this problem asks for.

The payoff for the entire borrow system

This is the item where the machinery starts paying rent.

A slice — &str, &[T] — is a borrowed view of memory somebody else owns: a pointer and a length, no allocation, no copying. &s[..i] is O(1) and touches no bytes. It is exactly the thing C++ calls string_view and Go calls a subslice.

The difference is that in C++ and Go, a view can outlive what it views, and the result is a use-after-free or a silently mutated window. Every C++ codebase over a certain age has a string_view dangling bug. In Rust the lifetime is part of the type, and the compiler will not let the view escape the data.

Walk the Book’s progression to see why this is not a small thing.

Version 1 — return an index.

fn first_word(s: &str) -> usize { /* index of the first space */ }

let mut s = String::from("hello world");
let i = first_word(&s);
s.clear();               // fine! `i` is just a number
// ... and now `i` points into a string that no longer exists.

The usize has silently desanchored. It was meaningful with respect to a particular string at a particular moment, and nothing in its type records that. The bug is not detectable by the compiler, because there is nothing to detect: usize is usize.

Version 2 — return a slice.

fn first_word(s: &str) -> &str { /* ... */ }

let mut s = String::from("hello world");
let w = first_word(&s);
s.clear();               // error[E0502]
println!("{w}");

The identical mistake is now a compile error, because w is a borrow of s and clear needs exclusive access. Nothing was added to the runtime. The return type simply carries information the usize threw away.

That is the trade the whole borrow system is making: put the relationship in the type, and a class of bugs becomes unrepresentable.

No lifetime annotation needed — and why

Notice what you did not write:

pub fn first_word<'a>(s: &'a str) -> &'a str

The elided form compiles and means exactly that. Rust’s lifetime elision rules say: each elided input lifetime gets its own parameter, and if there is exactly one lifetime-carrying input parameter, the elided output lifetime is taken from it. One input, one output, no ambiguity.

Take that same shape and add a second reference parameter and the elision stops working — you get E0106: missing lifetime specifier, because rustc will not guess which input the output borrows from. Track 8 is entirely about that. For now, appreciate that the common case needs no annotation at all.

The other code worth recognising is E0716: temporary value dropped while borrowed — what you get if you try to return a slice of something you built inside the function. You cannot; the local dies at the }. A &str return can only ever be a view of an input.

Two practical warnings

Slicing is byte-indexed. &s[..i] counts bytes, and it panics if i is not a UTF-8 character boundary. find(' ') and rfind(' ') return byte offsets of a one-byte ASCII character, so the indices they hand you are always safe. Slicing at an arbitrary number is not. (Clippy has a restriction lint, string_slice, that bans &s[..] outright in codebases that want that guarantee; it is allow-by-default and not part of this gate.)

Reslicing. &s[..] where s is already a &str is a no-op that clippy flags as redundant_slicing. And a for i in 0..s.len() loop over a string is both wrong (bytes, not chars) and lint-flagged (needless_range_loop).

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…