Academy
Strings in Rust: Why Two Types Isn't a Bug, It's a Feature
Discover why Rust has two string types (String and &str), how UTF-8 impacts memory efficiency, and how to write high-performance, idiomatically sound code.
8 menit baca
Di halaman ini
- String: Owned, Growable, Yours to Keep
- Common String Operations
- &str: Borrowed, Lightweight, Zero-Cost
- The Golden Rule: Accept &str, Return String
- UTF-8: International by Default, Indexed by Intention
- Broader Implications: Performance Through Honesty
- Practical Applications & Strategic Takeaways
- Our Commitment to Open Knowledge
- Support Our Mission & Get Your Handbook
If you've just arrived from Python, JavaScript, or Java, Rust's string situation is going to feel like walking into a restaurant and being told there are two menus. "Would you like the String menu or the &str menu?" you're asked, and before you can respond, someone at the next table mutters something about OsString, CString, and Cow<str>, and you start wondering if you accidentally wandered into a linguistics department.
Here's what nobody tells beginners: every language has multiple string types. Python has str and bytes. Java has String, StringBuilder, StringBuffer, and char[]. C++ has std::string, std::string_view, const char*, char[], and whatever CString your Windows API is using this week. The difference is that other languages hide the complexity behind a single default type and let you discover the performance implications later—usually in production, usually at scale, usually at the worst possible moment.
Rust makes the complexity visible. String and &str are two types because they represent two genuinely different things: owned, heap-allocated, growable text versus borrowed, zero-copy, lightweight text views. The distinction isn't Rust being difficult. It's Rust being honest about a fundamental tension that exists in every language's string handling—and giving you the tools to make conscious decisions about allocation, ownership, and performance rather than hoping the runtime figures it out.
At RantAI, where our AI pipelines process enormous volumes of multilingual text—parsing documents, tokenizing inputs, generating outputs—the difference between "copying a 10MB string because the language doesn't distinguish between owned and borrowed" and "passing a zero-cost reference to the existing data" is the difference between a system that handles real-world workloads and one that melts under the load. This article, drawn from Chapter 4, Section 4.2 of our guide "The Rust Programming Language," explains both string types, why you need them, and the idiomatic patterns that make working with them natural.
String: Owned, Growable, Yours to Keep#
fn main() {
let mut s = String::from("Hello");
s.push_str(", world!"); // Append text
s.push('!'); // Append a single character
println!("{}", s); // "Hello, world!!"
println!("Capacity: {}", s.capacity()); // How much memory is allocated
println!("Length: {}", s.len()); // How much is used
}
String is the type you use when you need to own text. It lives on the heap, manages its own memory, can grow and shrink dynamically, and follows Rust's ownership rules—when it goes out of scope, its memory is freed automatically. Think of it as Vec<u8> that's guaranteed to contain valid UTF-8.
You create String values when you need to build text dynamically, store text in a data structure, return text from a function, or receive text from user input or file I/O. If the text needs to outlive the current scope or needs to be modified, you want String.
fn create_greeting(name: &str) -> String {
format!("Hello, {}! Welcome to Rust.", name)
}
fn main() {
let greeting = create_greeting("Alice");
println!("{}", greeting);
// greeting owns the heap-allocated string
// It's freed when greeting goes out of scope
}
Common String Operations#
fn main() {
let s = String::from("Hello, Beautiful World!");
// Searching
println!("Contains 'Beautiful': {}", s.contains("Beautiful")); // true
println!("Starts with 'Hello': {}", s.starts_with("Hello")); // true
// Transforming (creates new Strings — originals unchanged)
println!("Uppercase: {}", s.to_uppercase()); // "HELLO, BEAUTIFUL WORLD!"
println!("Replaced: {}", s.replace("Beautiful", "Rust"));
// Splitting
for word in s.split_whitespace() {
println!("Word: {}", word);
}
// Trimming
let padded = " hello ".to_string();
println!("Trimmed: '{}'", padded.trim()); // "hello"
}
&str: Borrowed, Lightweight, Zero-Cost#
fn main() {
let full: String = String::from("Hello, Beautiful World!");
let slice: &str = &full[0..5]; // A view into the String — no copy!
let greeting: &str = "Hello"; // String literal — &str by default
println!("Slice: {}", slice); // "Hello"
println!("Literal: {}", greeting); // "Hello"
// full is still valid, unchanged, and complete
}
&str is a string slice—a reference to string data that lives somewhere else. It's just a pointer and a length, nothing more. It doesn't own the data, doesn't allocate memory, and doesn't copy anything. Creating a &str from a String is essentially free—it's just taking a reference.
String literals ("hello") are &str by default. They're baked into the binary at compile time and live for the entire duration of the program (they have the 'static lifetime). When you write let s = "hello";, you're creating a reference to data that's embedded in the executable.
The Golden Rule: Accept &str, Return String#
// GOOD: accepts &str — maximum flexibility for callers
fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
// GOOD: returns String — clear ownership transfer
fn capitalize_first(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let upper: String = first.to_uppercase().collect();
upper + chars.as_str()
}
}
}
fn main() {
let owned = String::from("hello world");
let literal = "hello world";
// Both work! &str accepts both String references and string literals
println!("Words in owned: {}", word_count(&owned));
println!("Words in literal: {}", word_count(literal));
println!("{}", capitalize_first("hello")); // "Hello"
}
By accepting &str, word_count works with any string type—String (via auto-deref), &str, or even slices of other strings. By returning String, capitalize_first makes ownership clear: the caller gets a brand-new, independently-owned string. This pattern is idiomatic Rust, and following it consistently makes your APIs both flexible and clear.
UTF-8: International by Default, Indexed by Intention#
All Rust strings are UTF-8 encoded. This is a deliberate design decision with important consequences:
fn main() {
let greeting = String::from("こんにちは、Rust!");
println!("{}", greeting); // Japanese + English, no problem
println!("Bytes: {}", greeting.len()); // 23 (UTF-8 byte count)
println!("Characters: {}", greeting.chars().count()); // 9 (Unicode character count)
// Iterating by character — always correct
for ch in greeting.chars() {
print!("{} ", ch); // こ ん に ち は 、 R u s t !
}
println!();
}
Notice that len() returns bytes, not characters. "こ" is 3 bytes in UTF-8, so the byte count (23) differs from the character count (9). This is why Rust doesn't let you index strings with s[0]—a byte index might land in the middle of a multi-byte character, which would be a bug. Instead, you iterate by characters (.chars()), by bytes (.bytes()), or by grapheme clusters (with the unicode-segmentation crate).
This might seem inconvenient compared to Python's s[0], but Python is doing something you don't realize: it uses UTF-32 internally (4 bytes per character) to make indexing work, which means every Python string uses 2-4x more memory than the equivalent Rust string. Rust's UTF-8 encoding is more memory-efficient for the vast majority of real-world text—especially text that's primarily ASCII with occasional international characters.
Broader Implications: Performance Through Honesty#
The String/&str distinction maps directly to Rust's ownership model. When a function takes &str, it's promising not to allocate or copy—a zero-cost operation. When it returns String, it's transferring ownership of heap-allocated data—an operation with a clear cost. These aren't just type system details; they're performance contracts enforced by the compiler.
At RantAI, our text processing pipelines pass &str references through dozens of transformation stages without a single unnecessary allocation. When we need to create new text, we use String and the ownership system ensures it's freed exactly once, at exactly the right time. The result is text processing that's both memory-efficient and safe—a combination that most languages can't offer because they don't give you the tools to distinguish between "I'm looking at this text" and "I own this text."
Practical Applications & Strategic Takeaways#
For newcomers: Accept &str in function parameters, return String when creating new text. This covers 90% of use cases. When the compiler complains about lifetimes with &str, it's usually telling you that you're trying to return a reference to data that won't live long enough—switch to returning String instead.
For C++ veterans: Think of String as std::string and &str as std::string_view—but with ownership enforced by the compiler. You can't accidentally create a dangling &str because the borrow checker prevents it. All the performance benefits of string_view, none of the dangling-reference risk.
For performance engineers: Every String::from(), .to_string(), or .clone() on a string is a heap allocation. Use &str references to avoid them. In hot paths, the difference between passing &str (one pointer copy) and passing String (one heap allocation + copy) can be significant at scale.
Our Commitment to Open Knowledge#
RantAI is committed to open education. String handling—including String, &str, UTF-8 encoding, and idiomatic patterns—is covered in Chapter 4, Section 4.2 of our guide, "The Rust Programming Language," freely available online.
Explore these concepts further: https://trpl.rantai.dev
Support Our Mission & Get Your Handbook#
If this article has clarified why Rust has two string types—or at least convinced you that it's not as weird as it first seems—consider supporting RantAI's educational mission.
Get the Handbook on Amazon KDP: https://www.amazon.com/dp/B0DHCMD3F2
Get the Handbook on Google Play Books: https://play.google.com/store/books/details?id=INwfEQAAQBAJ
What tripped you up most about Rust strings? The two types? UTF-8 indexing? The moment when .to_string() vs .as_str() finally made sense? Share your string journey below—every developer's "aha" moment is different, and yours might help someone else get there faster.
#RustLang #Strings #UTF8 #OwnershipModel #RantAI #LearnRust #ZeroCopy #Performance #SystemsProgramming #TextProcessing
Artikel berikutnya
Lifetimes Demystified: The Compiler Feature Everyone Fears (But Shouldn't)