10.2 &str: A String Slice (Pointer + Length)
Right, let’s talk about &str. You’ve probably met its more demanding, heap-allocated cousin, String. String is the one that’s always asking for more—more memory, more capacity, more responsibility. &str is the chill, minimalist friend. It doesn’t own anything. It’s a guest, a spectator, a borrowed view. Specifically, it’s a string slice, and it’s one of the most brilliantly designed parts of Rust.
Think of a &str as a data-scientist’s perfect pointer: it’s not just a memory address. It’s a two-part value:
- A pointer to the start of some UTF-8 text that someone else owns (be it a
String, the program’s binary, or a static variable). - A length, telling you exactly how many bytes long that text is.
This simple combo—pointer and length—is why it’s so powerful and ubiquitous. It’s a fat pointer, but don’t call it that to its face; it’s sensitive about the terminology.
The Anatomy of a &str
Let’s make this concrete. Imagine you have a String. A &str is a window into it.
let sable_antelope_facts = String::from("The Sable antelope has scimitar-shaped horns.");
// Here, we borrow a slice of the entire string.
let my_slice: &str = &sable_antelope_facts;
In memory, it looks something like this (simplified):
[ String Data on the Heap: "The Sable antelope has scimitar-shaped horns." ]
^
|
my_slice.ptr -------+
my_slice.len = 49 --+
my_slice contains a pointer to the first byte of the String’s data and a length of 49 bytes. It doesn’t own the heap allocation; it just has a view. This is why it’s immutable by default—you can’t mutate something you’re just borrowing a view of, lest the owner get rightfully upset.
Slicing the Slice (It’s Slices All the Way Down)
You don’t have to take a slice of the whole thing. The real power comes from taking slices of slices. The syntax [start..end] lets you specify a range of bytes.
let specific_fact = &my_slice[4..10]; // "Sable"
println!("{}", specific_fact);
But here’s the first Rust foot-gun, and it’s a doozy: those indices are byte offsets, not character indices. Rust strings are UTF-8, and characters can be multi-byte. Let’s be the idiot who learns the hard way so you don’t have to.
let emoji_text = String::from("rust🦀ferris");
// Let's try to slice out "ferris"
let bad_slice = &emoji_text[5..11]; // PANIC!
This will cause a runtime panic with the brilliantly clear message thread 'main' panicked at 'byte index 5 is not a char boundary; it is inside '🦀' (bytes 4..8) of rust🦀ferris'.
Why? The crab emoji ‘🦀’ is a single Unicode scalar value, but it’s encoded as 4 bytes in UTF-8 ([0xF0, 0x9F, 0xA6, 0x80]). Index 5 points into the middle of that byte sequence, which is meaningless on its own. Rust protects you from creating invalid UTF-8, which is a core guarantee of the &str type, by forcing these operations to panic if they aren’t on a character boundary.
The correct, safe way is to use methods that return indices for you, like .find().
let ferris_start = emoji_text.find("ferris").unwrap();
let good_slice = &emoji_text[ferris_start..];
println!("{}", good_slice); // prints "ferris"
The Static &'static str
Not all &str values point to heap-allocated String data. A lot of them point to text baked directly into your program’s binary.
let my_message: &'static str = "Hello, world!";
This string literal is stored in the read-only data segment of your executable. The borrow checker sees this and gives it a 'static lifetime, meaning it’s valid for the entire duration of the program. It’s the ultimate cheap string—no allocation, no copy, just a pointer and a length into your pre-existing binary. This is why you see &'static str everywhere in Rust examples; it’s the easiest one to write without any ceremony.
Why This Design is Genius (and Occasionally Annoying)
The String/&str dichotomy solves a classic systems programming problem: how to efficiently work with string data without drowning in copies or memory unsafety.
- Zero-Cost Slicing: Creating a substring is insanely cheap. It’s just creating a new pointer and length on the stack. No heap allocation, no copying of the actual text. This is a massive performance win.
- Memory Safety: The pointer and length are always correct and in sync. You never have a “null-terminated” string where you lose the length and walk off into memory oblivion. The borrow checker ensures the view can’t outlive the data it’s viewing.
- UTF-8 Guarantee: Every
&stris guaranteed to be valid UTF-8. This is a hard line Rust draws. It means you can always safely hand it off to other systems that expect UTF-8 without checking. The cost, as we saw, is that you have to be mindful of byte indices.
The “annoying” part is that you can’t just use integer indices willy-nilly. You have to think about the actual byte representation of your text. But let’s be honest, that’s not a design flaw; it’s a design feature. It’s forcing you to be correct, which is the whole point of using Rust. The methods are all there (.chars(), .char_indices(), .get(n..m) for a bounds-checked option that returns an Option<&str>) to do the work safely. Use them. Your program will be faster and more robust for it.
So embrace the slice. It’s not a limitation; it’s a superpower. It’s the key to writing efficient, idiomatic Rust.