Skip to content

Strings

String owns a growable UTF-8 buffer, &str borrows a view of UTF-8 text, and neither lets you index characters by position.

Java has an immutable String and a mutable StringBuilder. Both are UTF-16 inside. charAt(i) returns a UTF-16 code unit, and substring(a, b) works on code-unit positions. For most European text this is the same as “characters”, so most Java code never notices the difference.

Java

static String greeting(String name) {
var text = new StringBuilder("Dear "); // mutable builder
text.append(name);
text.append(',');
return text.toString(); // immutable String
}

Rust

pub fn greeting(name: &str) -> String {
let mut text = String::from("Dear "); // owned, growable
text.push_str(name); // `name` is a borrowed `&str`
text.push(',');
text
}
pub fn demo() -> (String, String) {
let literal: &str = "Ada"; // a literal is a `&'static str`
let owned: String = literal.to_string(); // a new, owned copy
let part: &str = &owned[0..2]; // a borrowed slice of `owned`: "Ad"
(greeting(literal), greeting(part)) // `&String` and `&str` both work
}

Rust has two main string types:

Type What it is Java analogy
String Owned, growable, heap-allocated UTF-8 text. StringBuilder and String in one type.
&str A borrowed view of UTF-8 text: a pointer and a length. A read-only CharSequence, or a substring that does not copy.

A String can be changed in place (push_str, push) if the variable is mut. A &str cannot be changed. Every String gives a &str for free: &owned or owned.as_str(). String literals such as "Ada" are &'static str: views of text that is part of the program.

pub fn greeting(name: &str) -> String {
let mut text = String::from("Dear "); // owned, growable
text.push_str(name); // `name` is a borrowed `&str`
text.push(',');
text
}
pub fn demo() -> (String, String) {
let literal: &str = "Ada"; // a literal is a `&'static str`
let owned: String = literal.to_string(); // a new, owned copy
let part: &str = &owned[0..2]; // a borrowed slice of `owned`: "Ad"
(greeting(literal), greeting(part)) // `&String` and `&str` both work
}
  1. Line 1: The function reads name and returns new text. So it borrows a &str and returns an owned String. This is the normal shape.
  2. Line 2: String::from makes an owned copy. let mut allows changes.
  3. Line 9: A literal is a &str. No allocation.
  4. Line 11: &owned[0..2] borrows a slice of the String. The range is in **bytes**, not characters. Here it is safe, because "Ad" is ASCII.
  5. Line 12: greeting takes &str, so both a literal and a slice work. A &String would work too: it converts to &str automatically.

Rust strings are always valid UTF-8. A character can take one to four bytes. So s[0] does not compile: there is no fast, correct answer to “the character at position 0”:

Does not compile: E0277
let sku = String::from("Ä-100");
let first = sku[0]; // error E0277: the type `str` cannot be indexed by `{integer}`

Instead, you say what you want: bytes or characters.

pub fn describe(sku: &str) -> (usize, usize, Option<char>) {
let bytes = sku.len(); // length in bytes (UTF-8), not characters
let chars = sku.chars().count(); // number of Unicode scalar values
let first = sku.chars().next(); // the first character, if any
(bytes, chars, first)
}
  • .len() is the length in bytes. For “Ä-100” it is 6, not 5.
  • .chars() iterates over Unicode scalar values (char, four bytes each). .chars().count() is the number of characters, but it reads the whole string.
  • .bytes() iterates over the raw bytes.

Slicing with a range, &s[a..b], is allowed, but the range is in bytes. If a or b is past the end, or inside a multi-byte character, the program panics. On text from users or files, that is a crash waiting for the first name with an accent. See S24: Indexing (v[i], s[a..b]) on untrusted input.

You will see all of these. They all produce a new, owned String:

pub fn all_the_same(name: &str, id: u64) -> [String; 5] {
[
name.to_string(), // from `Display`: works for any displayable value
name.to_owned(), // "make an owned copy of this borrowed value"
String::from(name), // explicit conversion
name.into(), // conversion chosen by the target type
format!("{name}#{id}"), // build a new string from parts
]
}

All five are correct. Codebases differ in which they prefer. to_string() and to_owned() are the most common for &str. format! is for building text from parts. What matters in a review is not which one, but how many: each one allocates.

File paths are not always valid UTF-8 (on Linux, a file name can be any bytes). So Rust has separate types:

  • PathBuf (owned) and &Path (borrowed) for paths. They are to each other as String is to &str.
  • OsString and &OsStr for text from the operating system: environment variables, command-line arguments, file names.
pub fn report_path(dir: &Path, customer: &str) -> PathBuf {
dir.join("reports").join(format!("{customer}.csv")) // `PathBuf` is to `&Path` as `String` is to `&str`
}

When you see a function that takes &str for a path and calls format!("{dir}/{name}"), suggest &Path and join. It handles separators, and it does not fail on non-UTF-8 names.

  • Parameters are &str. A function that reads text takes &str. A constructor that keeps the text takes String (or impl Into<String>). See S05: Owned parameters (String, Vec<T>) where a borrow is sufficient.
  • Return values are String when the function builds new text, and &str when it returns part of its input.
  • No position arithmetic on text. Use split, split_once, strip_prefix, trim, find, char_indices, and get(..).
  • Joining and building: join(",") for lists, push_str or write! for building in a loop, one format! for a fixed template.
  • Paths are Path and PathBuf, not String.

The task: show a preview of the first 10 characters of a product description.

  1. WorseA byte slice: panics on short text, and on text where byte 10 is inside a character.

    /// Panics if `text` is shorter than 10 bytes, or if byte 10 is in the
    /// middle of a multi-byte character.
    pub fn preview(text: &str) -> &str {
    &text[..10]
    }
    Run in Playground (opens the Rust Playground in a new tab)
  2. BetterFinds the byte position of the 11th character. Never panics, and counts characters, not bytes.

    /// The first 10 characters, or the whole text if it is shorter.
    pub fn preview(text: &str) -> &str {
    match text.char_indices().nth(10) {
    Some((end, _)) => &text[..end], // `end` is on a character boundary
    None => text,
    }
    }
    Run in Playground (opens the Rust Playground in a new tab)

The “worse” version passes every test with ASCII text of 10 or more characters. It panics on “short”, and on “Grüße für Köln”, where byte 10 is in the middle of a “ü”. The tests in examples/concepts/src/strings.rs show both panics.

And building a CSV line:

  1. Worse+ in a loop, and format! only to convert a &str that is already text.

    pub fn csv_line(fields: &[&str]) -> String {
    let mut line = String::new();
    for (i, field) in fields.iter().enumerate() {
    if i > 0 {
    line = line + ",";
    }
    line = line + &format!("{}", field);
    }
    line
    }
    Run in Playground (opens the Rust Playground in a new tab)
  2. Betterjoin does the same in one call, with one allocation.

    pub fn csv_line(fields: &[&str]) -> String {
    fields.join(",")
    }
    Run in Playground (opens the Rust Playground in a new tab)

The “worse” version is correct. It is a direct translation of a Java += loop. Each format! allocates a new String, and each + can reallocate the line. The cost is small for a few fields, but the code is longer than it needs to be.

  1. Why does name[0] not compile on a String?

    Show the answer

    UTF-8 characters have different lengths, so “the character at index 0” is not a simple lookup. Rust makes you choose: name.chars().next() for the first character, or name.as_bytes()[0] for the first byte.

  2. What does len() return for the text Köln?

    Show the answer
    1. len() is the length in bytes, and “ö” is two bytes in UTF-8. The number of characters is chars().count(), which is 4.
  3. A function has the parameter customer: String and only uses it in format!. What do you suggest?

    Show the answer

    Change it to customer: &str. Callers with a String pass &name, and callers with a literal pass it directly. No allocation for the call.

  4. When do you see PathBuf instead of String?

    Show the answer

    For file paths. Paths can contain bytes that are not valid UTF-8, and Path::join handles separators for each operating system.