Strings are one of the first data types you encounter in any programming language. In many languages, they are simple and straightforward. In Rust, however, the topic of strings often trips up newcomers. Why? Because Rust’s focus on memory safety and performance exposes distinctions that other languages hide.
The key to understanding strings in Rust is to grasp that there isn’t just one type. There are two main types you’ll work with: String and &str (a string slice). Let’s break them down.
The Core Difference: Ownership
The fundamental difference between String and &str boils down to ownership:
-
String: An owned, heap-allocated, growable, mutable string buffer. When you have aString, you are responsible for its memory. It’s like owning a house; you can modify it, expand it, and you’re responsible for its upkeep. -
&str(string slice): A borrowed, fixed-size, usually immutable view into a string’s data. It’s a pointer to some bytes along with a length. It’s like having a temporary key to a house; you can look inside, but you can’t change the structure, and your access is temporary.
A Closer Look at &str (String Slices)
A string slice is a reference to a sequence of UTF-8 bytes.
String Literals
The most common form of &str you’ll see is a string literal.
// filepath: examples/literals.rs
fn main() {
let literal = "Hello, Rust!"; // This has the type &'static str
println!("{}", literal);
}
The type of literal here is &'static str. This means it’s a string slice that lives for the entire duration of the program ('static). The text “Hello, Rust!” is hardcoded directly into the program’s executable file, and the literal variable is a reference pointing to that memory location.
Slicing a String
You can also get a &str by “slicing” an existing String or another string slice.
// filepath: examples/slicing.rs
fn main() {
let s = String::from("hello world");
let hello = &s[0..5]; // slice of the first 5 bytes
let world = &s[6..11]; // slice of the last 5 bytes
println!("{} {}", hello, world); // Output: hello world
}
Here, hello and world are &str slices that borrow a part of the String s. They don’t own the data; they just point to it.
When to Use &str
Use &str when you only need to read or view string data, not own it. The most common use case is for function parameters.
By accepting a &str, your function becomes more flexible. It can accept both an owned String and a borrowed &str.
// filepath: examples/fn_params.rs
// This function accepts any string slice.
fn print_message(message: &str) {
println!("{}", message);
}
fn main() {
let owned_string = String::from("Owned string");
let string_literal = "String literal";
// You can pass a reference to a String
print_message(&owned_string);
// You can also pass a string literal directly
print_message(string_literal);
}
This works because of a powerful feature in Rust called Deref Coercion, which can automatically convert a &String into a &str.
A Closer Look at String
A String is a structure that owns its data on the heap. This means it can be modified and resized at runtime.
Creating a String
There are several ways to create a String.
// filepath: examples/create_string.rs
fn main() {
// Create an empty string
let mut s1 = String::new();
// Create a string from a literal
let s2 = String::from("initial contents");
// Another way to create from a literal
let s3 = "initial contents".to_string();
}
Modifying a String
Since String is growable, you can add data to it.
// filepath: examples/modify_string.rs
fn main() {
let mut s = String::from("foo");
println!("Initially: {}", s);
// Append a string slice
s.push_str("bar");
println!("After push_str: {}", s); // Output: foobar
// Append a single character
s.push('!');
println!("After push: {}", s); // Output: foobar!
}
You can also concatenate strings using the + operator or the format! macro.
// filepath: examples/concat_string.rs
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
// s1 is moved here and can no longer be used
let s3 = s1 + &s2;
println!("{}", s3);
// A better way for complex formatting is the format! macro
let tic = String::from("tic");
let tac = String::from("tac");
let toe = String::from("toe");
let formatted = format!("{}-{}-{}", tic, tac, toe);
println!("{}", formatted);
}
Important: Using + moves ownership. In s1 + &s2, s1 is consumed, while s2 is only borrowed. The format! macro, however, borrows all its arguments and is usually the preferred method.
The Catch: UTF-8 and Indexing
A crucial point is that both String and &str are wrappers over a sequence of u8 bytes, and they are guaranteed to be valid UTF-8.
Because UTF-8 characters can be 1 to 4 bytes long, you cannot index into a Rust string with an integer like you would in other languages.
// filepath: examples/no_index.rs
fn main() {
let s = String::from("hello");
// let h = s[0]; // This will not compile!
}
This is a deliberate design choice to prevent bugs. Accessing s[0] is ambiguous: do you want the first byte or the first character? They might not be the same. For example, the Cyrillic character “З” is two bytes, and an emoji like “😂” is four bytes.
To access characters, you must be explicit.
// filepath: examples/iterate_string.rs
fn main() {
let hello = "Здравствуйте"; // "Hello" in Russian
// Iterate over Unicode scalar values (chars)
for c in hello.chars() {
print!("{} ", c);
}
println!();
// Iterate over raw bytes
for b in hello.bytes() {
print!("{} ", b);
}
println!();
}
Summary: When to Use Which?
-
Use
Stringwhen:- You need to own the string data.
- You need to modify or grow the string.
- You are returning a string that was created inside a function.
-
Use
&strwhen:- You just need a view of a string.
- You are writing a function that accepts string data. This makes your function more flexible.
- You are returning a slice of an existing string (but be mindful of lifetimes!).
Understanding the distinction between owned Strings and borrowed &str slices is a major step toward mastering Rust’s ownership system and writing safe, efficient code.