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

Implement both functions. Each returns a slice of the input: a &str that points into s rather than a newly allocated String.

The contract

The only separator is the ASCII space character ' ' (U+0020). Tabs, newlines and other whitespace are ordinary characters here.

  • first_word returns everything before the first space. If s contains no space, it returns all of s.
  • last_word returns everything after the last space. If s contains no space, it returns all of s.
input           first_word   last_word
"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"
"  "            ""           ""

Rules that follow from this definition:

  • An empty result is valid. A space at the start makes first_word empty, and a space at the end makes last_word empty.
  • Runs of spaces are not merged. In "a b", first_word is "a" and last_word is "b", because only the first and the last space matter.
  • Non-ASCII text is allowed anywhere and is returned unchanged. For example, first_word("héllo wörld") is "héllo".

The grader calls both functions on the same input and compares the two returned strings exactly.

Why the starter fails

The starter uses split_whitespace(). That method splits on any Unicode whitespace, treats a run of whitespace as one separator, and skips leading and trailing whitespace. So for " lead" it returns "lead" as the first word, and for "trail " it returns "trail" as the last word, while this contract requires "" in both places. It would also split "a\tb" at the tab. split_whitespace() is a useful method; it just computes a different rule from the one tested here.

What a slice is

A string slice &str (and, for other element types, &[T]) is a borrowed view of data that something else owns. At run time it is a pointer and a length. Making one with &s[..i] does not allocate and does not copy the text. For &str, the range is checked first (it must lie within the string and fall on character boundaries, explained below), so slicing is a constant-time operation, not a free one.

Because the slice is a borrow, its type records which data it borrows from. The compiler then refuses any use of the slice after that data could have been changed or freed. C++'s std::string_view is also a pointer and a length, but the C++ language does not track what it points into. Some compilers warn about simple cases, but in general a view that outlives its string compiles, and using it is undefined behavior.

The following comparison is adapted from The Rust Programming Language (chapter 4.3).

Returning an index. Suppose first_word returned the byte index where the first word ends:

fn first_word_end(s: &str) -> usize {
    s.find(' ').unwrap_or(s.len())
}

fn main() {
    let mut s = String::from("hello world");
    let end = first_word_end(&s); // 5
    s.clear(); // compiles: `end` is a plain number, not a borrow
    // `end` is still 5, but `s` is now empty, so the index is stale.
    // `&s[..end]` would panic here because 5 is past the end.
    println!("{end}");
}

The number 5 only meant something for the string as it was when first_word_end ran. Nothing in the type usize records that link, so the compiler cannot warn you when it breaks.

Returning a slice. With the signature from this exercise, the same mistake does not compile:

fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

fn main() {
    let mut s = String::from("hello world");
    let w = first_word(&s); // `w` borrows from `s`
    s.clear();              // error[E0502]: cannot borrow `s` as mutable
                            // because it is also borrowed as immutable
    println!("{w}");
}

clear needs a mutable borrow of s, and w is a shared borrow of s that is still used on the next line. The check happens entirely at compile time; the slice has no run-time bookkeeping. Putting the relationship in the return type is what lets the compiler reject this particular kind of stale reference.

Why no lifetime annotation is needed

The signature does not name a lifetime, but it means the same as:

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

This works because of the lifetime elision rules. A lifetime is the compiler's name for the region of code in which a borrow is valid. When a function signature leaves lifetimes out, each elided lifetime in the parameters gets its own lifetime parameter, and if the parameters use exactly one lifetime, that lifetime is given to every elided lifetime in the return type. (A further rule covers methods that take &self or &mut self.) Here there is one reference parameter, so the returned &str borrows from s.

With two reference parameters the rules no longer pick one, so this does not compile:

fn longer(a: &str, b: &str) -> &str {
    if a.len() >= b.len() { a } else { b }
}
// error[E0106]: missing lifetime specifier

You fix that by writing the lifetime parameters yourself, which is outside the scope of this exercise.

The elided signature also limits what the function can return. It may return a borrow of s, or data that lives for the whole program, such as the string literal "" (type &'static str). It cannot return a slice of a String created inside the function, because that String is dropped when the function returns:

fn first_word_upper(s: &str) -> &str {
    let upper = s.to_uppercase();
    &upper[..]
}
// error[E0515]: cannot return value referencing local variable `upper`

Slicing a &str uses byte offsets

The positions in &s[..i] are byte offsets into the UTF-8 encoding, not character counts. Slicing panics if an end point is past the end of the string or falls inside a multi-byte character. find(' ') and rfind(' ') return the byte offset where a space starts, and a space is one byte long, so both that offset and the offset just after it (i + 1) are always valid slice points. A number computed some other way, such as by counting characters, may not be.

Your submission is graded on three things: it must compile, pass the test cases, and pass cargo clippy -- -D warnings (every Clippy warning is treated as an error). One lint you may meet: writing &s[..] when s is already a &str produces an identical slice and is reported by clippy::redundant_slicing. Clippy also has an opt-in lint, clippy::string_slice, for projects that want to forbid &str range slicing entirely; it is not enabled by default and is not part of this grade.

The diagram below shows the same idea for a Vec: two slices borrow different parts of v at once, and both are shared borrows of v.

Loading visualization…