Level 4 · Deep Cuts

Strings, For Real This Time 🧵

Every language makes strings look simple. Rust makes them look exactly as complicated as they really are, which feels rude until the day a user named José shows up and your program does not crash.

The two strings, one more time 👯

You have been using both since Level 2. Now let's name the difference precisely. &str is a view into text that lives somewhere else: fixed size, borrowed, cheap. String is owned, growable text on the heap, like a Vec of characters you can push onto:

fn main() {
    let s: &str = "hello";                 // borrowed view, fixed size
    let mut owned: String = s.to_string(); // owned, growable
    owned.push_str(", world");
    println!("{owned}");
}

The rule of thumb you will use forever: take &str as a function parameter, return String when you build new text. A &String automatically shrinks into a &str when passed, so functions that accept &str accept everything.

UTF-8: bytes are not letters 🌍

Here is the part most languages hide. Text is stored as UTF-8 bytes, and one on-screen character can take 1, 2, 3, or 4 bytes. An e is one byte; an è is two; the crab 🦀 is four:

fn main() {
    let word = "crème brûlée 🦀";
    println!("chars: {}", word.chars().count());
    println!("bytes: {}", word.len());
    for c in "héy".chars() {
        print!("[{c}]");
    }
    println!();
}
chars: 14
bytes: 20

Notice .len() counts bytes, not letters. Fourteen visible characters, twenty bytes. Languages that pretend these are the same number work great right up until they meet the real world.

Why you cannot index a string 🚫

In many languages word[0] gives you the first letter. Rust refuses to compile it, and now you know why: word[0] would be the first byte, which might be only a third of a letter. Rust will not hand you a third of a letter. You can slice by byte ranges, but only on character boundaries:

fn main() {
    let word = "crème";
    let first = &word[0..1];   // fine: 'c' is exactly 1 byte
    println!("first: {first}");
    let accent = &word[2..4];  // fine: 'è' occupies bytes 2 and 3
    println!("accent: {accent}");
    // let broken = &word[0..3]; // ❌ panics: byte 3 is the middle of 'è'
}

Uncomment that last line and run it: the panic message even tells you which character you sliced through. When you actually want "the first letter," say what you mean: word.chars().next() gives an Option<char>, honest as always.

🔬 Deeper still: graphemes Even char is not the full story. Some emoji, like family emoji or flags, are several chars glued together into one visible symbol, called a grapheme cluster. When that matters (cursors, text editors), the unicode-segmentation crate handles it. For most programs, chars() is exactly right.

The methods you will actually use 🛠️

fn main() {
    let raw = "  42, rust, crab  ";
    let clean = raw.trim();
    for part in clean.split(", ") {
        println!("part: {part}");
    }

    let n: i32 = "42".parse().expect("not a number");
    println!("doubled: {}", n * 2);

    println!("{}", "rust is safe".replace("safe", "fast AND safe"));
    println!("{}", clean.contains("crab"));
}

The greatest hits: trim, split and split_whitespace, parse, replace, contains, starts_with, to_uppercase. Almost all of them return new values and leave the original alone, which by now should feel very Rust.

Building strings 🧱

fn main() {
    let words = vec!["fearless", "concurrency"];

    let joined = words.join(" ");
    println!("{joined}");

    let mut banner = String::new();
    banner.push_str("🦀 ");
    banner.push_str(&joined);
    banner.push('!');
    println!("{banner}");

    let shouted: String = joined.to_uppercase();
    println!("{shouted}");
}

push_str appends a string slice, push appends a single char, join glues a list together, and format! (which you have used since Lesson 1) builds a String from anything printable. That is 95% of real string work.

Exercise

The word flipper

Take the sentence "rust makes strings honest" and print it with every word reversed but the word order kept: tsur sekam sgnirts tsenoh. You will want split_whitespace, chars().rev(), and two collects.

Reveal solution
fn main() {
    let sentence = "rust makes strings honest";
    let reversed: Vec<String> = sentence
        .split_whitespace()
        .map(|word| word.chars().rev().collect())
        .collect();
    println!("{}", reversed.join(" "));
}

Two collects doing two jobs: the inner one gathers reversed chars into a String, the outer one gathers the words into a Vec. And because you used chars() instead of bytes, it even survives "héllo wörld". 🎉

🧠 The takeaway Rust strings feel strict because text is hard, and Rust refuses to pretend otherwise. The payoff: the whole category of "works in the demo, breaks on the first accented name" bugs simply does not exist here.