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

Write a function that takes a list of words, uppercases the first character of each word, and joins the results into one string with a single space between consecutive words.

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

The exact contract

  • Each element of words is one word. The function does not split, trim or otherwise inspect the text inside an element.
  • The first character of each word is replaced by its Unicode uppercase form, exactly as char::to_uppercase produces it. Some characters have an uppercase form that is more than one character long, for example 'ß' becomes "SS".
  • Everything after the first character is kept unchanged, including any letters that are already uppercase.
  • A character with no uppercase form, such as a digit, stays as it is.
  • An empty word stays empty. It still takes its place in the join, so ["a", "", "b"] gives "A B" (two spaces).
  • The words are joined in their original order with exactly one space between each pair. An empty list gives "".

Examples:

Input Output
["hello", "world"] "Hello World"
[] ""
["a"] "A"
["rust", "IS", "fun"] "Rust IS Fun"
["élan", "vital"] "Élan Vital"
["ßeta"] "SSeta"
["1st", "place"] "1st Place"

Note that this is plain uppercasing of the first character, not the Unicode "titlecase" mapping. The two differ for a few characters: titlecase would turn 'ß' into "Ss", but this exercise expects "SS".

The starter does not compile. Fixing the compiler error is not enough on its own: the most direct fix still panics on non-ASCII words such as "élan". Its parameter type, &Vec<String>, also works but is less flexible than &[String]. The sections below cover each of these.

Choosing parameter and return types

A common default for functions that work with text is:

Take &str (a borrowed view of text). Return String (owned text) when you build new text. Store String in structs that must own their data.

A &str parameter accepts many kinds of caller. Someone with a String named s passes &s, someone with a string literal passes it directly, and someone with part of a string passes a slice such as &s[2..7]. For a list of strings, the same idea gives &[String], a borrowed view of any contiguous sequence of String values: a Vec<String>, an array, or part of either.

Returning String fits this function because it builds new text. The result is not part of any input, so the function must hand back text the caller owns. (A function that returns part of its input can return a &str borrowed from that input instead.)

Clippy has a lint for the parameter half of this guideline. ptr_arg warns about a parameter of type &Vec<T> and suggests &[T], and warns about &String and suggests &str. It does not catch every case: it stays silent when the parameter is used in a way it cannot prove a slice would support. With Clippy 1.98.1, the starter's for word in words loop over the &Vec is one such use: once the compile error is fixed, that loop does not trigger ptr_arg, while for word in words.iter() does. (Clippy cannot lint the starter as given, because it does not compile.) Change the parameter to &[String] anyway, as the signature above shows. This does not break callers that pass &vec: Rust converts a &Vec<String> to a &[String] automatically.

E0369: two &str values cannot be added

The starter tries this:

let capitalized = &word[0..1] + &word[1..];

Both operands are &str, and the compiler rejects it:

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

The compiler also offers a suggestion to remove the first &. Following it produces a different error (cannot add &str to str), because word[0..1] on its own is a str, not an owned String.

The reason is how + is defined for strings. The standard library implements it like this:

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

There is no implementation of + for &str + &str. The left operand must be an owned String, and add takes it by value (self), so the addition consumes it. The result reuses the left string's buffer and appends the right side to it. That buffer may still need to grow, which can allocate, but no separate new string is created for the result.

Because the left String is consumed, you cannot use it afterwards:

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

format! works differently. It only reads its arguments and always builds a new String:

let a = String::from("foo");
let b = format!("{a}bar");   // `a` is only read and is still usable
println!("{a} {b}");         // prints "foo foobar"

So + moves its left operand and format! borrows its arguments. When you join more than two pieces, format! is often easier to read. It is not useful for turning a value that is already a String into a String: format!("{}", s) with s: String makes Clippy's useless_format lint suggest s.to_string() instead.

Uppercasing the first character safely

Do not take the first character with a byte range. &word[0..1] means "bytes 0 to 1", not "the first character". Rust strings are UTF-8, where a character can take from one to four bytes. 'é' takes two, so &"élan"[0..1] cuts it in half and the program panics at run time with a message like end byte index 1 is not a char boundary. For an empty word, &word[0..1] also panics because byte 1 is past the end. There are tests with "élan" and "ßeta".

Instead, step through the characters:

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

chars.next() returns the first character and leaves the iterator positioned just after it. After that call, chars.as_str() returns the rest of the word, unchanged, as a &str. Together they split any word, including an empty one, into "first character" and "the rest" without cutting a character in half.

char::to_uppercase returns an iterator of char values, not a single char, because some characters uppercase to more than one character: 'ß' becomes "SS". You can collect it into a String with .to_string() or .collect::<String>(), and then append the rest of the word.

One more Clippy lint

needless_borrow warns when you write & in front of an expression that is already a reference that the compiler could use directly. For example, if word is a &String (as it is inside for word in words) and a function takes a &str, passing &word creates a &&String that the compiler then has to dereference again; passing word is enough. If it fires, remove the extra &.