blog.dopana

Back

Strings in Rust are notoriously confusing for beginners. Why does Rust have both String and &str? Why can’t you index a string with s[0]? Understanding Rust’s string architecture unlocks how the language guarantees memory safety and international UTF-8 correctness.

Explain Like I’m 10: The Notebook vs The Page Bookmark#

Imagine two ways of working with text:

  1. String (The Expandable Spiral Notebook): A physical notebook you own on your desk (Heap memory). You can write new sentences, erase paragraphs, add extra blank pages (mut), or pass it around.
  2. &str (The Transparent Bookmark View): A clear bookmark you lay over a specific line in a printed library book or someone else’s notebook. You can read the text underneath without copying or owning the paper.
graph TD
    subgraph HeapMemory ["Heap Allocation"]
        StringBuf["String Buffer (UTF-8 Bytes in Heap)"]
    end
    subgraph StackVariables ["Stack Representation"]
        StringObj["String struct<br/>[ ptr | len | capacity ]"] -->|Points to owned data| StringBuf
        StrSlice["&str slice<br/>[ ptr | len ]"] -->|Borrows a view of| StringBuf
        LiteralStr["static str slice<br/>[ ptr | len ]"] -->|Points to read-only binary text| ReadOnlyData["Binary Text Segment (.rodata)"]
    end

String vs &str: The Core Differences#

PropertyString&str (String Slice)
OwnershipOwns its heap bufferBorrows a view of string data
GrowthGrowable, shrinkable, mutable with mutFixed length, read-only view
LocationHeap-allocated buffer (stack holds pointer, len, cap)Stack holds fat pointer (ptr + len)
Common CreationString::new(), String::from("..."), s.to_string()"hello" (literal), &s[0..4], &s

Creating and Updating Strings#

[!NOTE] The + operator in s1 + &s2 calls a method signature fn add(self, s: &str) -> String. It takes ownership of s1, appends a copy of s2, and returns the modified buffer.

Why Rust Prohibits Direct Indexing (s[0])#

In many languages, s[0] returns the first character. In Rust, this is a compile-time error:

let s = String::from("hello");
// let h = s[0]; // ❌ Error: the type `String` cannot be indexed by `{integer}`
rust

The UTF-8 Reason#

Rust strings are always valid UTF-8 sequences. Different Unicode characters occupy different numbers of bytes (from 1 to 4 bytes):

graph LR
    subgraph English ["English: 'Hello' (1 byte / char)"]
        H["'H' [0x48]"] --- E["'e' [0x65]"] --- L1["'l' [0x6C]"] --- L2["'l' [0x6C]"] --- O["'o' [0x6F]"]
    end
    subgraph Vietnamese ["Vietnamese: 'Chào' (Multi-byte)"]
        C["'C' [0x43]"] --- H2["'h' [0x68]"] --- AU["'à' [0xC3, 0xA0] (2 bytes)"] --- O2["'o' [0x6F]"]
    end
    subgraph Emoji ["Emoji / Japanese: '🦀' (4 bytes)"]
        Crab["'🦀' [0xF0, 0x9F, 0xA6, 0x80] (4 bytes)"]
    end

If Rust allowed s[0], returning a single byte would often return half a Unicode character, breaking your program silently. Furthermore, indexing would require an O(n) traversal to count multi-byte glyphs, violating Rust’s zero-cost abstraction design.

Iterating Over Strings: Bytes vs Chars#

To inspect strings safely, explicitly choose your unit of iteration:

String Slicing Safety Warning#

You can slice strings with ranges, but range boundaries must land on valid UTF-8 character boundaries:

src/main.rs
fn main() {
    let hello = "Здравствуйте";
    
    // Each Cyrillic character takes 2 bytes.
    let s = &hello[0..4]; // Takes first 2 characters (4 bytes) -> "Зд"
    println!("{s}");

    // ❌ Crashing slice: dividing inside a character
    // let invalid = &hello[0..1]; // Panics at runtime: byte index 1 is not a char boundary
}
rust

[!WARNING] Always verify boundary safety or prefer iterator methods like .chars().take(n) over manual byte range slicing on international text.

Passing Strings to Functions: Idiomatic &str#

When writing functions that only need to read string data, always accept &str rather than &String:

src/main.rs
// Idiomatic: accepts both &String and &str thanks to Deref coercion
fn print_length(text: &str) {
    println!("Length: {} bytes", text.len());
}

fn main() {
    let owned = String::from("Rustacean");
    let literal = "Rustacean";

    print_length(&owned);   // Auto deref &String -> &str
    print_length(literal);  // Direct &str
}
rust

Summary#

  • String is an owned, heap-allocated, growable UTF-8 buffer.
  • &str is an immutable borrowed view (slice) into UTF-8 data.
  • Direct integer indexing is prohibited because UTF-8 characters vary between 1 and 4 bytes.
  • Use .chars() to iterate over Unicode characters and .bytes() for raw byte representation.
  • Accept &str as function arguments for maximum flexibility and zero allocation cost.

References#