When developers switch to Rust from high-level languages like Python or JavaScript, one of the first mental speedbumps is the string system.
Why are there two main string types (String vs &str)? What actually happens when a function returns "Yummy!"? And why is a string slice called a “fat pointer”?
In this post we’ll look under the hood at how Rust lays out string literals, string slices, and pointers in memory — and why this design makes Rust strings fast and memory-efficient.
1. Literal vs. Slice
Two terms get conflated a lot, so let’s separate them:
-
Literal: text hardcoded directly into your
.rssource file (e.g.,"Hello, world!"). -
Slice (
&str): a view into a contiguous run of valid UTF-8 bytes somewhere in memory, without owning them.
The relationship between the two:
Every string literal is a string slice, but not every string slice is a string literal.
Example
fn main() {
// 1. A string literal — embedded in the compiled binary.
// Type: &'static str
let literal: &str = "Hello, world!";
// 2. A dynamic String — allocated at runtime on the heap.
let mut user_input = String::new();
user_input.push_str("Rustacean");
// 3. A slice into that heap string.
// Type: &str (points into user_input's heap buffer, not the binary)
let dynamic_slice: &str = &user_input[0..4]; // "Rust"
// Both are &str, so the same function accepts either.
print_slice(literal); // "Hello, world!"
print_slice(dynamic_slice); // "Rust"
}
fn print_slice(slice: &str) {
println!("Printing slice: {}", slice);
}
Enter fullscreen mode Exit fullscreen mode
-
literalis a string literal, so its type is&'static str— it’s embedded directly in the binary. -
dynamic_sliceis a valid&str, but not a literal — it was carved out ofuser_inputat runtime and didn’t exist in your source code.
2. Normal Pointer vs. Fat Pointer
In C, a string pointer is a plain 8-byte address (on 64-bit). String functions scan forward byte-by-byte until they hit a null terminator (\0).
Rust drops null terminators entirely. Instead, &str is a fat pointer: two 64-bit fields packed back to back, 16 bytes total on a 64-bit target.
┌─────────────────────────┬─────────────────────────┐
│ Pointer (8 bytes) │ Length (8 bytes) │
├─────────────────────────┼─────────────────────────┤
│ 0x00007FFF00401050 │ 6 │
└─────────────────────────┴─────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
- Pointer: the address of the first byte.
- Length: the number of bytes in the slice.
Note: this ptr-then-length picture is the right mental model, but Rust doesn’t guarantee that exact field ordering as part of its stable ABI. What is guaranteed is that a
&str/&[T]reference is two machine words wide.
3. What Happens When a Function Returns "Yummy!"?
fn picky_eater(food: &str) -> &str {
if food == "strawberry" {
"Yummy!"
} else if food == "potato" {
"I guess I can eat that."
} else {
"No thanks!"
}
}
Enter fullscreen mode Exit fullscreen mode
Step A: The read-only data segment (.rodata)
At compile time, string literals like "Yummy!" get written into the binary’s read-only data segment (.rodata). When the OS loads the binary, that data lands at a fixed address in RAM for the entire life of the program — which is exactly why literals get the 'static lifetime.
Say the OS loads "Yummy!" at 0x00007FFF00401050:
0x00007FFF00401050
0x59
'Y'
0x00007FFF00401051
0x75
'u'
0x00007FFF00401052
0x6D
'm'
0x00007FFF00401053
0x6D
'm'
0x00007FFF00401054
0x79
'y'
0x00007FFF00401055
0x21
'!'
Step B: Constructing and returning the fat pointer
Rust doesn’t copy those 6 bytes onto the stack. It just constructs a 16-byte fat pointer:
-
Pointer =
0x00007FFF00401050 -
Length =
6
Since the underlying bytes live in .rodata for the program’s entire lifetime, returning this pointer is completely safe — you’re never pointing at stack memory that disappears when the function returns.
4. Zero-Cost Slicing (and its one sharp edge)
Because length is metadata on the pointer itself, sub-slicing is zero-cost:
let full: &str = "Yummy!";
let sub: &str = &full[1..5]; // "ummy"
Enter fullscreen mode Exit fullscreen mode
No new allocation, no copy — just a new 16-byte fat pointer:
Memory: [ 'Y' | 'u' | 'm' | 'm' | 'y' | '!' ]
Address: ...1050 ...1051 ...1052 ...1053 ...1054 ...1055
▲ ▲ ▲
│ └──────────────┬──────────────┘
│ │
`full`: ptr = ...1050, len = 6 │
`sub`: ptr = ...1051, len = 4 ─────┘
Enter fullscreen mode Exit fullscreen mode
-
full: pointer =0x...1050, length =6("Yummy!") -
sub: pointer =0x...1051(offset by 1 byte), length =4("ummy")
Both slices point into the exact same memory.
The gotcha: those slice indices are byte offsets, and Rust requires them to land on UTF-8 character boundaries. &full[1..5] works because every character in "Yummy!" is a single ASCII byte. Slice a multi-byte character (say, an emoji or accented letter) in the middle of its encoding, and Rust panics at runtime rather than handing back corrupted UTF-8. Coming from Python, where str[1:5] just silently does the “wrong” thing on multi-byte content, this is worth internalizing early.
5. Summary Cheat Sheet
Concept Definition Lifetime / Memory Size Literal Text hardcoded in source ("...").
'static, lives in .rodata.
Embedded in the binary.
Slice (&str)
A view (pointer + length) into valid UTF-8 bytes.
Depends on what it borrows.
16 bytes (fat pointer).
String
Owned, growable, heap-allocated UTF-8 buffer.
RAII — dropped when it goes out of scope.
24 bytes (pointer, length, and capacity fields).
Why this design matters
-
O(1) length lookups —
.len()costs nothing at runtime; the length is already sitting in the fat pointer. - Zero-copy passing — handing a 1 GB string slice to a function copies 16 bytes of pointer metadata, not the string.
답글 남기기