Drawing the owner and the view¶
Level: 101 → 201 · working knowledge
One line: "String owns, &str borrows" is the abstract version. The physical version is a drawing: three words and a heap buffer for the owner, and two words pointing into that same buffer for the view. Once you can draw it, each borrow rule about strings is something the drawing already rules out.
let s = String::from("héllo world");
let world: &str = &s[7..];
stack heap: s's buffer, 12 bytes
offset 0 1 2 3 4 5 6 7 8 9 10 11
┌──────────────────┐
│ s ptr ●────┼────────────────► 68 c3 a9 6c 6c 6f 20 77 6f 72 6c 64
│ len 12 │ h é · l l o ␣ w o r l d
│ cap 12 │ ▲
├──────────────────┤ │
│ world ptr ●────┼───────────────────────────────────────┘
│ len 5 │
└──────────────────┘
One buffer, two handles. world copied no bytes. It is an address 7 bytes into s's buffer plus the number 5. It is 7 and not 6 because é is the two bytes c3 a9. · marks the byte that continues it, and ␣ is the space.
Abstract and physical¶
A rule is abstract when you can state it: a borrow cannot outlive its owner. It is physical when you can point at the bytes it protects: world is an address inside a buffer that s frees at its closing brace.
Try the test on any line of string code. Without running it, can you say:
- how many words each handle takes up;
- which buffer each pointer lands in, and at what offset;
- which line frees that buffer.
If you can, you have the drawing. If you can only recite the rule, start with Stack and heap and The anatomy of a String, then come back. This is the sense of physical in the How to learn ToOwned path's "Before step 1" note.
The drawing, checked against the machine¶
drawing_the_owner_and_the_view.rs prints each part of the picture. It gives sizes in machine words, so they hold on a 32-bit target too. It gives addresses as offsets into s's buffer, because the raw addresses change on every run. Two runs of a scratch version that printed s.as_ptr() put the buffer at 0x7f85721042f0 and then at 0x7fb147706000. What an address shows explains why a printed address is a weak thing to learn from.
Verified output of drawing_the_owner_and_the_view.rs — regenerated by tools/run_examples.py, never hand-typed.
1. Two handles, measured in machine words
String 3 words a pointer, a length and a capacity
&str 2 words a pointer and a length
2. The owner's buffer, one column per byte
s = "héllo world" len 12 capacity 12
offset 0 1 2 3 4 5 6 7 8 9 10 11
byte 68 c3 a9 6c 6c 6f 20 77 6f 72 6c 64
char h é · l l o ␣ w o r l d
3. The view points into that buffer
world = "world" len 5
starts at offset Some(7) of s's buffer
7, not 6: 'é' is the two bytes c3 a9
4. Making the view owned makes a second buffer
world.to_owned() == world true
its pointer, as an offset into s None
len 5 capacity 5 a buffer of its own
5. A move copies the handle and leaves the buffer where it is
let moved = s; buffer address changed: false
6. Even two bytes of text live on the heap, not inside the handle
String::from("hi") capacity 2 bytes inside the handle: false
Line by line:
- 1. The owner's handle has one more word than the view's: the capacity. std's
Stringdocs ↗ list the three parts. They do not promise an order, so the drawing's order is a choice. - 2. The buffer holds UTF-8 bytes, not characters. Byte 2 starts no character, which is why the
charrow shows·there. - 3. The view's pointer lands inside the owner's buffer. Nothing was copied, and nothing was allocated.
- 4.
to_owned()is the way from a view to an owner. Its pointer is in no part ofs's buffer: a second allocation, and a copy of 5 bytes into it. - 5. A move copies the three words. The buffer does not move, so a move costs the same for 12 bytes of text as for 12 megabytes.
- 6. Rust's
Stringnever stores text inside its handle, even 2 bytes of it. C++'sstd::stringdoes; see below.
Five errors the drawing explains¶
Each one is the compiler refusing to let a pointer on the left of the drawing outlive, or lose, the buffer on the right. Transcripts are from rustc 1.98.0 on the fence shown, saved under the file name the header gives. Transcripts are not regenerated on each commit. The fences are: check_fences.py confirms on every build that each broken fence still fails and each fix compiles.
1. The view outlives the buffer — E0597¶
fn main() {
let world: &str;
{
let s = String::from("héllo world");
world = &s[7..];
}
println!("{world}");
}
error[E0597]: `s` does not live long enough
--> e0597.rs:5:18
|
4 | let s = String::from("héllo world");
| - binding `s` declared here
5 | world = &s[7..];
| ^ borrowed value does not live long enough
6 | }
| - `s` dropped here while still borrowed
7 | println!("{world}");
| ----- borrow later used here
In the drawing. The inner } frees the buffer on the right. world's pointer still leads to it.
The fix. Give the text an owner that lives as long as the name does: own a copy, or move s out of the block.
fn main() {
let world: String;
{
let s = String::from("héllo world");
world = s[7..].to_owned();
}
println!("{world}");
}
2. The owner is dropped while the view points into it — E0505¶
fn main() {
let s = String::from("héllo world");
let world = &s[7..];
drop(s);
println!("{world}");
}
error[E0505]: cannot move out of `s` because it is borrowed
--> e0505.rs:4:10
|
2 | let s = String::from("héllo world");
| - binding `s` declared here
3 | let world = &s[7..];
| - borrow of `s` occurs here
4 | drop(s);
| ^ move out of `s` occurs here
5 | println!("{world}");
| ----- borrow later used here
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let world = &s.clone()[7..];
| ++++++++
In the drawing. Moving s into drop is how the buffer gets freed. A move copies the three words, but the thing that receives them decides when the buffer dies.
The fix. Finish with the view before you give the owner away. rustc's suggestion compiles too. Read it with the drawing in mind: s.clone() is a second buffer of 12 bytes, and world points into that.
fn main() {
let s = String::from("héllo world");
let world = &s[7..];
println!("{world}");
drop(s);
}
3. The owner grows while the view exists — E0502¶
fn main() {
let mut s = String::from("héllo world");
let world = &s[7..];
s.push_str(", and more");
println!("{world}");
}
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> e0502.rs:4:5
|
3 | let world = &s[7..];
| - immutable borrow occurs here
4 | s.push_str(", and more");
| ^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("{world}");
| ----- immutable borrow later used here
In the drawing. len 12, cap 12 leaves no room. Adding 10 bytes needs a bigger allocation, and the allocator is free to put it somewhere else: copy the bytes there, free the old buffer. world would still point at the old one. The anatomy of a String watches capacity jump as a string grows.
The fix. Grow first, then take the view. Or take the view as an offset (7) and slice again after growing.
fn main() {
let mut s = String::from("héllo world");
s.push_str(", and more");
let world = &s[7..12];
println!("{world}");
}
4. The view is asked to grow — E0599¶
error[E0599]: no method named `push_str` found for reference `&str` in the current scope
--> e0599.rs:4:11
|
4 | world.push_str("!");
| ^^^^^^^^ method not found in `&str`
In the drawing. A view has no capacity word and no buffer of its own. There is nowhere for a ! to go that is not somebody else's byte.
The fix. Make an owner, which is a new buffer, and grow that.
fn main() {
let s = String::from("héllo world");
let world = &s[7..];
let mut owned = world.to_owned();
owned.push_str("!");
println!("{owned}");
}
5. A view of a local leaves the function — E0515¶
fn world() -> &'static str {
let s = String::from("héllo world");
&s[7..]
}
fn main() {
println!("{}", world());
}
error[E0515]: cannot return value referencing local variable `s`
--> e0515.rs:3:5
|
3 | &s[7..]
| ^-^^^^^
| ||
| |`s` is borrowed here
| returns a value referencing data owned by the current function
In the drawing. The buffer belongs to the function's s, which is freed at the function's }. The two words returned to the caller would point at freed memory.
The fix. Return the owner, so the buffer travels with the three words.
fn world() -> String {
let s = String::from("héllo world");
s[7..].to_owned()
}
fn main() {
println!("{}", world());
}
Three lints that see the same drawing¶
Recorded with clippy-driver --edition 2024 from rustc 1.98.0 and clippy 0.1.98. Each transcript drops only the documentation link, the #[warn(...)] note and the summary line. Both programs in each pair compile under check_fences.py. The build does not re-run clippy, so a later clippy can reword a warning.
ptr_arg¶
clippy · style · warn by default · fires on a &String parameter
fn shout(s: &String) -> usize {
s.len()
}
fn main() {
let s = String::from("héllo world");
println!("{}", shout(&s));
}
warning: writing `&String` instead of `&str` involves a new object where a slice will do
--> ptr_arg.rs:1:13
|
1 | fn shout(s: &String) -> usize {
| ^^^^^^^
|
help: change this to
|
1 - fn shout(s: &String) -> usize {
1 + fn shout(s: &str) -> usize {
|
Silent:
fn shout(s: &str) -> usize {
s.len()
}
fn main() {
let s = String::from("héllo world");
println!("{}", shout(&s));
println!("{}", shout("a literal needs no String"));
}
In the drawing. A &String is one word pointing at the handle, which points at the buffer: two hops. A &str goes to the bytes in one hop. It also accepts a literal or a slice, neither of which has a String handle to point at. Right almost always. Noise only where a signature has to match an existing function-pointer type.
unnecessary_to_owned¶
clippy · perf · warn by default · fires on an owned copy made only to be borrowed
fn count_o(s: &str) -> usize {
s.matches('o').count()
}
fn main() {
let s = String::from("héllo world");
let world = &s[7..];
println!("{}", count_o(&world.to_owned()));
}
warning: unnecessary use of `to_owned`
--> unnecessary_to_owned.rs:8:28
|
8 | println!("{}", count_o(&world.to_owned()));
| ^^^^^^^^^^^^^^^^^ help: use: `world`
|
Silent:
fn count_o(s: &str) -> usize {
s.matches('o').count()
}
fn main() {
let s = String::from("héllo world");
let world = &s[7..];
println!("{}", count_o(world));
}
In the drawing. &world.to_owned() draws a second buffer, copies 5 bytes into it, points a view at it and frees it at the end of the statement. The view you already had pointed at the same 5 bytes. Right whenever it fires.
string_slice¶
clippy · restriction · allowed by default · fires on every &s[a..b] on a string
#![warn(clippy::string_slice)]
fn main() {
let s = String::from("héllo world");
let world = &s[7..];
println!("{world}");
}
warning: indexing into a string may panic if the index is within a UTF-8 character
--> string_slice.rs:5:18
|
5 | let world = &s[7..];
| ^^^^^^
|
note: the lint level is defined here
--> string_slice.rs:1:9
|
1 | #![warn(clippy::string_slice)]
| ^^^^^^^^^^^^^^^^^^^^
Silent:
#![warn(clippy::string_slice)]
fn main() {
let s = String::from("héllo world");
let world = s.get(7..).unwrap_or_default();
println!("{world}");
}
In the drawing. The 7 is a byte offset, and the byte row decides whether it starts a character. Here it does. 6 would too, and 2 would panic. The lint cannot see the bytes, so it flags every slice. Right in code that slices text it did not produce, such as user input or file contents. Noise where the offset came from find or char_indices, which only return offsets that start a character.
What no lint catches¶
- An offset kept after the string changed. A
usizeis not a borrow, so nothing ties7tos. String slices opens with that bug. - A view that keeps a big buffer alive for too long. Rust will not let a view outlive its buffer, but it will happily keep a 12 MB buffer alive because a 5-byte view is still in use. Nothing warns.
to_owned()on the small piece, then dropping the big one, is the fix, and it is a decision only you can make.
If you are coming from another language¶
C. A char * is the pointer word alone. There is no length, so strlen walks the bytes until it meets a 0, and nothing in the type says whether this pointer owns the buffer (you must free it) or borrows it (you must not). That is a comment, or a naming convention. The sibling library's a string is bytes up to a NUL ↗ is the C half of this drawing. What changes: Rust puts the length in the handle and the owner/view split in the type, and the compiler checks every view against its owner. The five errors above are the five ways C lets you get that check wrong.
C++. std::string and std::string_view are the same pair, and the numbers match. Measured with Apple clang 21 and libc++: sizeof(std::string) is 24, sizeof(std::string_view) is 16, and std::string_view(s).substr(7).data() - s.data() is 7. Two things differ. First, libc++ keeps short text inside the 24 bytes (the small-string optimisation): "hi"'s data() points inside the object, where Rust's line 6 above says false. Second, a std::string_view taken from a temporary std::string, through a function returning std::string_view(s).substr(7), compiled with -Wall -Wextra and no warning at all. In Rust the same shape is error 5. Lifetime safety in Clang is Clang's newer, opt-in attempt at that check.
Go. A string is the same two words: unsafe.Sizeof says 16. And owned[7:] shares the bytes: unsafe.StringData puts the substring at offset 7 of the original (go1.25.5). What changes: Go's garbage collector, not the compiler, keeps the buffer alive. So the view can never dangle, and the second item under What no lint catches becomes the normal case. Measured: a 5-byte substring of a 64 MB string kept all 64 MB on the heap after a runtime.GC(), until strings.Clone copied the 5 bytes out.
Python. str has no view. s[7:] builds a new str object, and only s[:] hands back s itself (CPython 3.14.7). memoryview(s) refuses a str with "a bytes-like object is required"; a view exists only over bytes, bytearray and friends, where memoryview(b)[8:] does share b's memory. The sibling library's slicing is not indexing ↗ covers the Python side. What changes: in Python a slice is a copy, so there is never a lifetime to think about. In Rust a slice is a view, and the lifetime is the thing you think about.
Practice¶
Three questions, from the drawing. Answer each on paper first, then write a program that checks your answer.
Stringalready knows its length. Why does a&strcarry a length of its own?- After
let t = s;, which bytes moved, and which stayed where they were? - Why is
&s[1..2]on"héllo"a panic at run time rather than a compile error?
Solution
three_questions_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: three questions answered from the drawing, then checked.
//!
//! rustc --edition 2024 three_questions_kata.rs -o /tmp/tqk && /tmp/tqk
use std::panic;
/// Where `view` starts, in bytes from the start of `owner`'s buffer.
fn offset_in(owner: &str, view: &str) -> Option<usize> {
let buffer = owner.as_bytes().as_ptr_range();
let start = view.as_ptr();
buffer.contains(&start).then(|| start.addr() - buffer.start.addr())
}
/// The same compiled code for every call: two numbers and a slice.
fn cut(s: &str, start: usize, end: usize) -> &str {
&s[start..end]
}
fn main() {
let s = String::from("héllo world");
println!("1. Why does a &str carry a length?");
let one = &s[..1];
let six = &s[..6];
for (label, view) in [("&s[..1]", one), ("&s[..6]", six)] {
let shown = format!("{view:?}");
println!(" {label} = {shown:<8} offset {:?} len {}", offset_in(&s, view), view.len());
}
println!(" Same pointer, different text. The pointer says where a view starts;");
println!(" only the length says where it stops. A C char * has no length word,");
println!(" so strlen walks the bytes until it finds a 0.");
println!();
println!("2. After `let t = s;`, which bytes moved?");
let buffer = s.as_ptr();
let t = s;
println!(" buffer address changed: {}", t.as_ptr() != buffer);
println!(" The three words were copied into t and s stopped being usable.");
println!(" The 12 bytes on the heap stayed where they were.");
println!();
println!("3. Why is &s[1..2] on \"héllo\" a panic and not a compile error?");
panic::set_hook(Box::new(|_| {}));
for text in ["hello", "héllo"] {
match panic::catch_unwind(|| cut(text, 1, 2).to_owned()) {
Ok(piece) => println!(" cut({text:?}, 1, 2) = {piece:?}"),
Err(payload) => {
let message = payload.downcast_ref::<String>().map_or("?", |m| m.as_str());
println!(" cut({text:?}, 1, 2) panicked: {message}");
}
}
}
let _ = panic::take_hook();
println!(" One function, compiled once. Its bounds are two usize values, and");
println!(" whether byte 2 starts a character depends on the bytes, which exist");
println!(" only when the program runs.");
println!(" \"héllo\".get(1..2) = {:?} the same question, without the panic", "héllo".get(1..2));
}
Verified output of three_questions_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
1. Why does a &str carry a length?
&s[..1] = "h" offset Some(0) len 1
&s[..6] = "héllo" offset Some(0) len 6
Same pointer, different text. The pointer says where a view starts;
only the length says where it stops. A C char * has no length word,
so strlen walks the bytes until it finds a 0.
2. After `let t = s;`, which bytes moved?
buffer address changed: false
The three words were copied into t and s stopped being usable.
The 12 bytes on the heap stayed where they were.
3. Why is &s[1..2] on "héllo" a panic and not a compile error?
cut("hello", 1, 2) = "e"
cut("héllo", 1, 2) panicked: end byte index 2 is not a char boundary; it is inside 'é' (bytes 1..3 of string)
One function, compiled once. Its bounds are two usize values, and
whether byte 2 starts a character depends on the bytes, which exist
only when the program runs.
"héllo".get(1..2) = None the same question, without the panic
Views into one buffer. Draw this before you run it:
fn main() {
let line = String::from("zażółć gęślą jaźń");
let words: Vec<&str> = line.split(' ').collect();
println!("{words:?}");
}
For each word, give its offset and its length in bytes inside line's buffer. Then say how many heap buffers exist, and what each one holds. Check it with as_ptr, and then work out what to_owned on every word would add.
Solution
views_into_one_buffer_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: split a String into words and find every word inside the
//! one buffer they share.
//!
//! rustc --edition 2024 views_into_one_buffer_kata.rs -o /tmp/vob && /tmp/vob
use std::mem::size_of;
/// Where `view` starts, in bytes from the start of `owner`'s buffer.
fn offset_in(owner: &str, view: &str) -> Option<usize> {
let buffer = owner.as_bytes().as_ptr_range();
let start = view.as_ptr();
buffer.contains(&start).then(|| start.addr() - buffer.start.addr())
}
fn main() {
let line = String::from("zażółć gęślą jaźń");
let words: Vec<&str> = line.split(' ').collect();
println!("1. Three views into one buffer of {} bytes", line.len());
println!(" {:<10} {:>6} {:>4} {:>6}", "word", "offset", "len", "chars");
for word in &words {
let offset = offset_in(&line, word).expect("every word points into line");
println!(" {:<10} {offset:>6} {:>4} {:>6}", format!("{word:?}"), word.len(), word.chars().count());
}
println!();
println!("2. The second buffer");
let handle_words = size_of::<&str>() / size_of::<usize>();
println!(" words holds {} handles of {handle_words} machine words each", words.len());
let handles = words.as_ptr().cast::<u8>();
println!(" and they sit in a buffer of their own: inside line's buffer? {}",
line.as_bytes().as_ptr_range().contains(&handles));
println!(" Text bytes copied to make the three words: none. Heap buffers: two,");
println!(" line's text and the Vec's array of handles.");
println!();
println!("3. What owning them would cost");
let owned: Vec<String> = words.iter().map(|w| (*w).to_owned()).collect();
let separate = owned.iter().all(|w| offset_in(&line, w).is_none());
println!(" to_owned on each word: {} more buffers, none inside line's: {separate}", owned.len());
}
Verified output of views_into_one_buffer_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
1. Three views into one buffer of 26 bytes
word offset len chars
"zażółć" 0 10 6
"gęślą" 11 8 5
"jaźń" 20 6 4
2. The second buffer
words holds 3 handles of 2 machine words each
and they sit in a buffer of their own: inside line's buffer? false
Text bytes copied to make the three words: none. Heap buffers: two,
line's text and the Vec's array of handles.
3. What owning them would cost
to_owned on each word: 3 more buffers, none inside line's: true
Run it yourself¶
rustc --edition 2024 14_Strings/drawing_the_owner_and_the_view/examples/drawing_the_owner_and_the_view.rs -o /tmp/dov && /tmp/dov
See also¶
- STRINGS.md — the map: every string lesson, in reading order
Stringvs&str— the owner and the view as a choice of parameter and field types- String slices — the view on its own: the stale index it replaces, and the byte offsets that panic
- The anatomy of a
String— the owner on its own: capacity, growth, and five bindings over two buffers stris unsized — why the view's second word is a length, and why there is never a barestron the stack- Stack and heap — the left and right halves of the drawing, for every type
- What an address shows — which of the two places
{:p}prints, and what a move does to each - A struct that points into itself — the owner and the view put in one struct, which compiles and can then never move
- Ownership and moves — line 5 of the output, as a rule
- Borrowing — errors 2 and 3, as a rule
- How to learn
ToOwned— the path that assumes this drawing, and whereto_owned()on line 4 is explained - UTF-8 by hand ↗ — why
éisc3 a9, in the encodings library - Drawing
sret— the same kind of drawing for a return value: the caller's room and the hidden pointer that fills it
Resources¶
Checked on 2026-09-16 against the documentation shipped with rustc 1.98.0 and the book files named.
- The Rust Programming Language, ch. 4 Understanding Ownership:
- 4.1 What Is Ownership? › Memory and Allocation ↗. Figure 4-1 draws a
Stringholding"hello", and Figures 4-2 to 4-4 draw whatlet s2 = s1;copies and what it invalidates. - 4.3 The Slice Type › String Slices ↗. Figure 4-7, A string slice referring to part of a String, draws
&s[6..11]of"hello world"as a pointer to byte 6 and a length of 5: the drawing at the top of this page, without the two-byteé. - std docs:
String› Representation ↗ names the three parts and the methods that show them (as_ptr,len,capacity).str::as_ptr↗ is how every offset on this page was measured. - Programming Rust, 2nd ed. (Blandy, Orendorff, Tindall), ch. 3 Fundamental Types › String Types › Strings in Memory. Figure 3-3, String, &str, and str, draws a
Stringwith an eight-byte buffer, a&strto its last six bytes, and a literal whose three characters take seven bytes. - 100 Exercises To Learn Rust — String slices ↗ draws the three words and a
&strinto the middle of the buffer.
Po polsku¶
Wiedzieć, że „String jest właścicielem, a &str pożycza”, to wiedza abstrakcyjna. Wiedza fizyczna to umiejętność narysowania pamięci. String to trzy słowa na stosie (wskaźnik, długość, pojemność) i bufor bajtów na stercie. &str to dwa słowa (wskaźnik i długość), a jego wskaźnik trafia do środka cudzego bufora. &s[7..] na "héllo world" nie kopiuje ani bajtu: to adres 7 bajtów od początku bufora s i liczba 5. Siódmy, a nie szósty, bo é zajmuje w UTF-8 dwa bajty, c3 a9.
Kto ma ten rysunek w głowie, nie musi uczyć się reguł pożyczania na pamięć. Każda z nich wynika z rysunku. Widok nie przeżyje bufora (E0597, E0505), bo wskazywałby na zwolnioną pamięć. Właściciel nie urośnie, póki widok istnieje (E0502), bo realokacja przenosi bufor. Widok nie urośnie wcale (E0599), bo nie ma pola pojemności ani własnego bufora. Nie da się też zwrócić widoku na zmienną lokalną (E0515), bo bufor ginie razem z funkcją. Polskie litery dają najlepsze ćwiczenie: w "zażółć gęślą jaźń" każde słowo ma więcej bajtów niż liter, więc przesunięcia (offsety) trzeba liczyć w bajtach, nie na palcach.
Szukaj po polsku: wycinek łańcucha znaków · stos i sterta · wskaźnik i długość · rust string slice memory layout · rust String vs &str memory