Rust’s String and &str aren’t just different types; they represent fundamentally different approaches to data management that will shape how you write safe, efficient Rust code.
Let’s see this in action. Imagine you have a function that needs to print a piece of text.
fn print_message(msg: &str) {
println!("Message: {}", msg);
}
fn main() {
let mut my_string = String::from("Hello");
my_string.push_str(", world!"); // my_string is now "Hello, world!"
print_message(&my_string); // We can pass a &str slice of our String
let literal = "This is a string literal";
print_message(literal); // We can also pass a string literal directly
}
Here, print_message accepts a &str. Notice that &my_string (a reference to our String) and literal (a string literal) are both valid arguments. This highlights a key distinction: String is an owned, growable string, while &str is a borrowed, immutable string slice.
The problem String and &str solve is managing mutable, heap-allocated data versus immutable, fixed-size string literals efficiently and safely. String is a Vec<u8> under the hood, meaning it lives on the heap. It has a pointer to the data, a length, and a capacity. Because it’s on the heap and owned by a specific variable, it can be modified: you can append to it, clear it, or grow its capacity. When a String goes out of scope, its memory is deallocated.
&str, on the other hand, is a string slice. It’s a "view" into a string. This view consists of two pieces of information: a pointer to the start of the string data (which could be in static memory for a literal, or on the heap as part of a String) and a length. Crucially, &str is immutable. You cannot change the data it points to through a &str. This immutability is enforced by Rust’s borrow checker, preventing data races and ensuring memory safety.
The magic happens with deref coercions. When you pass a &String to a function expecting a &str, Rust automatically converts &String into a &str. This is because String implements the Deref trait, allowing it to be treated like a &str in many contexts. This makes it incredibly convenient to work with string data.
The real power comes from understanding how to create and manipulate these types.
fn main() {
// Creating a String
let mut s = String::new(); // An empty, owned String
s.push_str("foo"); // Appending a &str
s.push('!'); // Appending a char
// Creating from a literal
let s2 = String::from("bar");
// Creating a &str slice from a String
let slice1 = &s[0..2]; // "fo" - a slice of the first two bytes
let slice2 = &s[..]; // "foo!" - a slice of the entire String
// String concatenation (less efficient than push_str for multiple appends)
let s3 = s2 + "baz"; // s2 is moved here. s3 is "barbaz"
// let s4 = s2 + "qux"; // ERROR: s2 has been moved
// Using format! macro (preferred for building strings)
let name = "Alice";
let age = 30;
let greeting = format!("Hello, {}! You are {} years old.", name, age);
}
When you see s2 + "baz", remember that the + operator for String actually calls the add method, which takes ownership of the String on the left (s2 in this case) and appends a &str to it, returning a new String. This is why s2 can’t be used afterward. The format! macro, however, is generally more efficient and readable for constructing complex strings as it avoids repeated reallocations and ownership transfers.
The distinction between owned String and borrowed &str is critical for understanding Rust’s memory management. String owns its data on the heap, and its lifetime is tied to the variable that owns it. &str is a borrow, a reference to existing string data, and its lifetime is tied to the data it points to and the scope in which the borrow is valid. This system prevents dangling pointers and ensures that borrowed data outlives the references to it.
The one thing most people don’t immediately grasp is how string slicing (&s[start..end]) works. It’s not byte-based in the sense that you can slice in the middle of a multi-byte UTF-8 character. If you try to create a slice that doesn’t fall on a character boundary, Rust will panic at runtime. This is a safety feature, ensuring that you always have valid UTF-8 strings. For example, if s contained "你好" (each character is 3 bytes in UTF-8), &s[0..1] would panic because it would be slicing in the middle of the first character. You must slice on character boundaries.
The next step in understanding string manipulation is dealing with mutable string slices and more advanced string operations like working with character iterators and grapheme clusters.