Skip to content

← Ground Rules: Values, Types, Control Flow step 23 of 24

Medium Primitives

String vs &str, part 2: functions that take and return text

Capitalise the first character of every word and join them with single spaces.

pub fn title_case(words: &[String]) -> String

["hello", "world"] gives "Hello World". [] gives "". ["a"] gives "A". Everything after the first character is left exactly as it was, so ["rust", "IS", "fun"] gives "Rust IS Fun".

The starter does not compile. Two things are wrong with it, and you will meet them one at a time.

The signature is the lesson

You read the memory model in the previous item. Here is the operational rule it produces, and it is worth memorising in this form:

Take &str. Return String. Store String.

Take &str (or &[String], as here) because a borrowed view accepts every caller: someone with a String passes &s, someone with a literal passes it directly, someone with a sub-range passes &s[2..7]. Return String because the caller needs something that outlives your function, and you are the one who built it.

Clippy enforces the first half mechanically: ptr_arg rejects a &String parameter and tells you to take &str, exactly as it rejected &Vec<T> in item 1.20. The starter takes &Vec<String> and will be told so. There is no lint for the return half — that one is on you.

E0369: &str + &str does not compile

The starter tries this:

let capitalized = &word[0..1] + &word[1..];
error[E0369]: cannot add `&str` to `&str`
   |
   |     &word[0..1] + &word[1..]
   |     ----------- ^ ---------- &str
   |     |
   |     &str
   = note: string concatenation requires an owned `String` on the left

This confuses everyone exactly once, and the reason is the type of +:

impl Add<&str> for String {
    fn add(self, other: &str) -> String
}

+ is implemented for String + &str, and for nothing else. The left operand must be an owned String and it is taken by value — so the addition consumes it, reusing its buffer to hold the result instead of allocating a new one. That is a deliberate performance decision, and it makes the ownership visible:

let a = String::from("foo");
let b = a + "bar";       // `a` is MOVED here
// println!("{a}");      // error[E0382]: borrow of moved value

Contrast:

let a = String::from("foo");
let b = format!("{a}bar");   // `a` is only READ; still usable afterwards

That contrast is a memorable, low-stakes first ownership lesson: + moves, format! borrows. format! is also the clearer choice for anything with more than two pieces. What format! is not good for is wrapping something that is already a String — clippy’s useless_format will tell you so.

Capitalising a character properly

Do not byte-slice. &word[0..1] takes the first byte, which is a char_boundary panic waiting for the first non-ASCII input — and there is a test with "élan" in it. Take the first character instead:

let mut chars = word.chars();
match chars.next() {
    Some(first) => …,          // `first` is a char
    None        => …,          // the word was empty
}

chars.next() consumes the first character and leaves the iterator positioned after it, so chars.as_str() hands you the untouched remainder as a &str. That is the clean idiom for “first character and the rest” and it is correct for every input.

char::to_uppercase returns an iterator, not a char, and this is not pedantry: some characters uppercase to more than one. 'ß' uppercases to "SS", and there is a hidden test for it. A function that returned a single char would have to be wrong somewhere, so the standard library refuses to offer one.

One more default-on lint

needless_borrow catches the &&String that beginners produce by taking a reference to something that was already a reference — usually inside a closure or a for pattern. If it fires, delete an ampersand.

Loading visualization…