How to learn lifetimes: is "clone everything" good advice?¶
Level: 201 · working knowledge
One line: Don't use references · Copy and clone everything · Obey the compiler is good advice given for slightly the wrong reason, and it needs three amendments — the most important being that cloning to escape a mutation error compiles, runs, and silently does nothing.
The advice¶
- Don't use references
- Copy & clone everything
- Obey the compiler
Verdict up front: yes, mostly — take it. It is the single fastest way past the wall that stops most beginners, and the objections people raise to it ("that's not idiomatic", "that's slow") are objections to it as a destination, which it was never meant to be.
But it is usually defended on the wrong grounds, and the standard defence hides the one hazard that can actually cost you an afternoon.
Why it works — and it is not "clones are cheap"¶
The usual justification is that a clone costs nothing at learning scale. True, and beside the point.
The real reason is about order of learning. Lifetimes are not a separate feature you can study; they are bookkeeping about ownership. 'a says "this reference must not outlive the thing it points at", which is meaningless until you have a solid model of what owns what and when it dies. Most beginners meet ownership and references simultaneously — chapter 4 introduces both — and then try to debug a lifetime error while their model of ownership is still forming.
"Clone everything" removes references from the problem, which reduces two entangled ideas to one. You spend a week with only ownership: values, moves, drops, one owner at a time. When you come back to references, & is a small addition to a model you already have rather than half of a model you have neither half of.
That is a scaffold, and a scaffold is supposed to come down.
Amendment 1: it is really "don't put references in structs"¶
Rule 1 taken literally is impossible. You cannot avoid references in Rust — &self methods, &str parameters, every iterator, println!("{x}"). Trying produces worse code than the borrow errors it avoids.
The precise version, and the one actually worth following:
Don't put references in struct fields.
That is where lifetimes become infectious. A struct Parser<'a> { input: &'a str } puts a lifetime parameter on the type, then on everything holding it, then on every function touching those — and each new annotation is a place to get it wrong. Meanwhile fn parse(input: &str) needs no annotation at all, because lifetime elision ↗ handles the common shapes for you.
Beginners are told "avoid references" and hear "avoid & in signatures", which is the one place references are free.
Amendment 2: cloning to write is a silent bug¶
This is the one the advice never mentions and the reason this page has an example.
Cloning to escape a read borrow is harmless waste. Cloning to escape a mutation borrow changes what the program does — you mutate the copy, the original is untouched, and nothing complains, because mutating a copy is a perfectly legal thing to want:
Verified output of how_to_learn_lifetimes.rs — regenerated by tools/run_examples.py, never hand-typed.
1. Cloning to read: wasteful, harmless, and fine while learning
total(&scores) = 12
total(&scores.clone()) = 12 <- same answer, one pointless copy
2. Cloning to WRITE: compiles, runs, silently does nothing
after add_ballot(&mut a, 4) a = [5, 3, 4] <- the push landed
after add_ballot_cloned(b.clone(), 4) b = [5, 3] <- it did not
no error, no warning, no panic. The compiler cannot help here:
mutating a copy is a perfectly legal thing to want.
3. `.clone()` does not mean one thing
Vec::clone -> a second, independent Vec (3 and 3 elements)
Rc::strong_count before = 1
Rc::clone -> the SAME Vec, one more owner
Rc::strong_count after = 2 (and both see [1, 2, 3])
Same method name, opposite meanings: one copies the data,
the other copies only the right to reach it.
So the amendment is a question to ask before every clone that silences an error:
Was I about to change this? If yes,
.clone()is not the fix —&mutis.
Note the third pair too: .clone() does not even mean one thing. On a Vec it duplicates the data; on an Rc it duplicates only the right to reach the data, and the underlying value stays shared. "Clone everything" quietly assumes the first meaning.
Amendment 3: obey the compiler, except when it says 'static¶
Rule 3 is the best of the three. rustc's diagnostics are extraordinary, its suggested fixes are usually right, and "try what it says" is a genuinely good default that beginners under-use because they assume the error is a rebuke.
One exception, and it is precisely in this topic. When a lifetime does not check out, one common suggestion is to add a 'static bound. Occasionally that is right. Often it is the compiler observing that one way to satisfy the constraint is for the data to live forever — which is a true statement about the type system and the wrong fix for your program. Adding 'static then propagates outward, and two hours later you are threading it through six signatures.
When you see 'static suggested, treat it as a question about your design — should this really be owned instead? — rather than an instruction. Everywhere else, do what it says.
The exit condition, which the advice does not give¶
The rule's real weakness is that it has no ending. Clone-everything is right for week one, fine for month one, and a genuine problem in a program that matters — not mainly for speed, but because it hides the design question of who owns what.
Two signals you are ready to stop:
- You can say why the borrow checker objected, not just what made it stop. "I'm holding a shared borrow across a mutation" is understanding; "I added
.clone()and it went green" is not. - A clone starts feeling like a lie about the design — you are copying a thing there is conceptually only one of.
Until then, keep cloning. The useful habit is to treat a clone-to-compile exactly as this library treats an unwrap: a placeholder you meant to revisit, that compiles quietly and waits. Same category of move, same fix — leave a note:
A grep for that comment later is the graduation exercise.
If you are coming from another language¶
- Python — you have been "cloning everything" the whole time; Python just did not charge you for it, because every name is a reference and the garbage collector settled the argument. Rust's version is explicit and the visible
.clone()is the price of the compiler being able to check the alternative. - ABAP — writing to an input parameter passed by value is exactly amendment 2:
USING VALUE(p)on aFORMandIMPORTING VALUE(p)on a method are local copies, so the change stays local and the caller's variable keeps its value.CHANGINGwrites back, andVALUE( )does not stop it: aCHANGING VALUE(p)copy is assigned back to the caller's variable when the subroutine ends normally, though not when a message or an exception ends it (FORM↗). Rust makes the same distinction with&mut, and enforces it rather than trusting the signature.
Practice¶
Return a palindrome you did not copy. Write longest_palindrome(s: &str) -> &str, returning a slice of the input rather than a new string. The signature needs no lifetime written, because one reference goes in and elision ties the result to it. "babad" holds two palindromes of length three and the test accepts only "bab", so a tie has to keep the first. Then write longer_palindrome(a, b), returning the longer of the two inputs' palindromes, leave the lifetime off, and read the E0106 that stops it.
// rustc --edition 2024 --test longest_palindrome.rs -o t && ./t
fn longest_palindrome(s: &str) -> &str {
todo!()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_longest_palindrome() {
assert_eq!(longest_palindrome("babad"), "bab"); // or "aba"
assert_eq!(longest_palindrome("cbbd"), "bb");
assert_eq!(longest_palindrome("a"), "a");
assert_eq!(longest_palindrome(""), "");
assert_eq!(longest_palindrome("racecar"), "racecar");
}
}
Solution
longest_palindrome_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: the longest palindromic substring, returned as a slice of
//! the input — one reference in, one out, and lifetime elision ties them.
//!
//! rustc --edition 2024 longest_palindrome_kata.rs -o /tmp/lpk && /tmp/lpk
//! rustc --edition 2024 --test longest_palindrome_kata.rs -o /tmp/lpkt && /tmp/lpkt
/// Expand around every centre — each character for odd lengths, each gap for
/// even ones — and keep the FIRST longest, which is why "babad" gives "bab"
/// and not "aba". Positions are char indices; the final slice converts them to
/// byte offsets taken from `char_indices`, so it cannot split a letter.
fn longest_palindrome(s: &str) -> &str {
let chars: Vec<char> = s.chars().collect();
let offsets: Vec<usize> = s.char_indices().map(|(i, _)| i).chain([s.len()]).collect();
let (mut start, mut end) = (0, 0);
for centre in 0..chars.len() {
for (mut lo, mut hi) in [(centre, centre + 1), (centre, centre)] {
while lo > 0 && hi < chars.len() && chars[lo - 1] == chars[hi] {
lo -= 1;
hi += 1;
}
if hi - lo > end - start {
(start, end) = (lo, hi);
}
}
}
&s[offsets[start]..offsets[end]]
}
/// Two inputs, one borrowed result: now elision has two references it could
/// tie the output to, and gives up. Without `<'a>` this signature is E0106.
fn longer_palindrome<'a>(a: &'a str, b: &'a str) -> &'a str {
let (pa, pb) = (longest_palindrome(a), longest_palindrome(b));
if pb.chars().count() > pa.chars().count() { pb } else { pa }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_longest_palindrome() {
assert_eq!(longest_palindrome("babad"), "bab"); // or "aba"
assert_eq!(longest_palindrome("cbbd"), "bb");
assert_eq!(longest_palindrome("a"), "a");
assert_eq!(longest_palindrome(""), "");
assert_eq!(longest_palindrome("racecar"), "racecar");
}
}
fn main() {
println!("1. Adam's five cases");
for (s, want) in [("babad", "bab"), ("cbbd", "bb"), ("a", "a"), ("", ""), ("racecar", "racecar")] {
let got = longest_palindrome(s);
assert_eq!(got, want);
println!(" {:<11} -> {got:?}", format!("{s:?}"));
}
println!(" \"babad\" holds both bab and aba, three long each. The comment in the");
println!(" test says either will do; the assert_eq! accepts only bab, so a tie");
println!(" has to keep the first one found: > in the comparison, not >=.");
println!();
println!("2. The answer is a view into the input, not a copy");
let text = String::from("xyracecarzw");
let p = longest_palindrome(&text);
let offset = p.as_ptr() as usize - text.as_ptr() as usize;
println!(" longest_palindrome({text:?}) = {p:?}, starting at byte {offset} of the input");
println!(" fn longest_palindrome(s: &str) -> &str needs no lifetime written:");
println!(" one reference goes in, so elision ties the result to it.");
println!();
println!("3. Two inputs, and the annotation elision cannot supply");
println!(" longer_palindrome(\"noon\", \"level\") = {:?}", longer_palindrome("noon", "level"));
println!(" fn longer_palindrome<'a>(a: &'a str, b: &'a str) -> &'a str");
println!(" Delete the <'a> and rustc stops with E0106: the result could borrow");
println!(" from either argument, and the signature has to say which it may.");
println!();
println!("4. Characters, so no slice lands inside a letter");
for s in ["été", "kajak", "ąbą"] {
println!(" {:<8} -> {:?}", format!("{s:?}"), longest_palindrome(s));
}
}
Verified output of longest_palindrome_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
1. Adam's five cases
"babad" -> "bab"
"cbbd" -> "bb"
"a" -> "a"
"" -> ""
"racecar" -> "racecar"
"babad" holds both bab and aba, three long each. The comment in the
test says either will do; the assert_eq! accepts only bab, so a tie
has to keep the first one found: > in the comparison, not >=.
2. The answer is a view into the input, not a copy
longest_palindrome("xyracecarzw") = "racecar", starting at byte 2 of the input
fn longest_palindrome(s: &str) -> &str needs no lifetime written:
one reference goes in, so elision ties the result to it.
3. Two inputs, and the annotation elision cannot supply
longer_palindrome("noon", "level") = "level"
fn longer_palindrome<'a>(a: &'a str, b: &'a str) -> &'a str
Delete the <'a> and rustc stops with E0106: the result could borrow
from either argument, and the signature has to say which it may.
4. Characters, so no slice lands inside a letter
"été" -> "été"
"kajak" -> "kajak"
"ąbą" -> "ąbą"
A log that keeps views, not copies. Load a thousand-line log and count its errors three ways, counting allocations as you go with a counting global allocator (The global allocator shows how): a Vec<String> holding a copy of every line; a struct Log<'a> { lines: Vec<&'a str> } that borrows a buffer owned outside it; and a struct that owns the buffer and keeps Range<usize> offsets instead of references. Before the third, try the design in between — one struct holding both the String and &str views into it, built inside a load function — and read the two errors it earns. That is amendment 1, met in the wild.
Solution
zero_copy_log_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: a log that keeps views of its lines instead of a `String` per
//! line — and the one struct design the borrow checker will not accept.
//!
//! rustc --edition 2024 zero_copy_log_kata.rs -o /tmp/zclk && /tmp/zclk
use std::alloc::{GlobalAlloc, Layout, System};
use std::ops::Range;
use std::sync::atomic::{AtomicUsize, Ordering::Relaxed};
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
/// Counts allocations and reallocations, so "zero-copy" is a number here.
struct Counting;
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOCS.fetch_add(1, Relaxed);
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
ALLOCS.fetch_add(1, Relaxed);
unsafe { System.realloc(ptr, layout, new_size) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn counted<T>(f: impl FnOnce() -> T) -> (T, usize) {
let before = ALLOCS.load(Relaxed);
let out = f();
(out, ALLOCS.load(Relaxed) - before)
}
/// A `String` per line: the obvious design, and an allocation for every line.
fn copies(text: &str) -> Vec<String> {
text.lines().map(str::to_owned).collect()
}
/// Views into a buffer that lives somewhere else. The lifetime is the whole
/// contract: this log cannot outlive the text it points into.
struct Borrowed<'a> {
lines: Vec<&'a str>,
}
impl<'a> Borrowed<'a> {
fn new(text: &'a str) -> Self {
Borrowed { lines: text.lines().collect() }
}
fn errors(&self) -> usize {
self.lines.iter().filter(|l| l.starts_with("ERROR")).count()
}
}
/// The buffer inside, and byte RANGES into it instead of references. There is
/// nothing for the borrow checker to connect, so this log can be returned,
/// stored and moved like any other value; a line is sliced out when asked for.
struct Owned {
buf: String,
spans: Vec<Range<usize>>,
}
impl Owned {
fn new(buf: String) -> Self {
let mut spans = Vec::new();
let mut start = 0;
for piece in buf.split_inclusive('\n') {
let line = piece.trim_end_matches(|c: char| c == '\n' || c == '\r');
spans.push(start..start + line.len());
start += piece.len();
}
Owned { buf, spans }
}
fn line(&self, i: usize) -> &str {
&self.buf[self.spans[i].clone()]
}
fn errors(&self) -> usize {
(0..self.spans.len()).filter(|&i| self.line(i).starts_with("ERROR")).count()
}
}
/// A function can hand an `Owned` log to its caller. It could not hand back a
/// `Borrowed` one built from a `String` it made itself: that is E0515.
fn load(lines: usize) -> Owned {
Owned::new(sample(lines))
}
/// Every hundredth line is an error, so the counts below are easy to check.
fn sample(lines: usize) -> String {
(0..lines)
.map(|i| if i % 100 == 0 { format!("ERROR at step {i}\n") } else { format!("ok {i}\n") })
.collect()
}
fn main() {
let text = sample(1000); // built before any counting starts
println!("1. A String per line");
let (lines, n) = counted(|| copies(&text));
println!(" {} lines, {n} allocations: one per line, and the Vec growing", lines.len());
println!();
println!("2. Views into one buffer, with the buffer outside");
let (log, n) = counted(|| Borrowed::new(&text));
println!(" {} lines, {n} allocations: only the Vec of views growing", log.lines.len());
println!(" {} of them are errors", log.errors());
println!();
println!("3. The buffer inside, and ranges instead of references");
let copy = text.clone(); // the log will own this copy; made before counting
let (owned, n) = counted(move || Owned::new(copy));
println!(" {} lines, {n} allocations: the Vec of ranges growing, no text copied", owned.spans.len());
println!(" line 0 {:?}, line 1 {:?}, {} errors", owned.line(0), owned.line(1), owned.errors());
let loaded = load(3);
println!(" load(3) returns a log that owns its text: {:?}", (0..3).map(|i| loaded.line(i)).collect::<Vec<_>>());
println!();
println!("4. The design in between does not compile");
println!(" A struct holding the buffer AND views into it needs a lifetime that names");
println!(" the struct itself. Building one from a local String is E0515, since the");
println!(" views point into a local, and E0505, since moving the buffer into the");
println!(" struct moves it while it is borrowed. Ranges are the way out: an offset");
println!(" borrows nothing.");
}
Verified output of zero_copy_log_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
1. A String per line
1000 lines, 1009 allocations: one per line, and the Vec growing
2. Views into one buffer, with the buffer outside
1000 lines, 9 allocations: only the Vec of views growing
10 of them are errors
3. The buffer inside, and ranges instead of references
1000 lines, 9 allocations: the Vec of ranges growing, no text copied
line 0 "ERROR at step 0", line 1 "ok 1", 10 errors
load(3) returns a log that owns its text: ["ERROR at step 0", "ok 1", "ok 2"]
4. The design in between does not compile
A struct holding the buffer AND views into it needs a lifetime that names
the struct itself. Building one from a local String is E0515, since the
views point into a local, and E0505, since moving the buffer into the
struct moves it while it is borrowed. Ranges are the way out: an offset
borrows nothing.
See also¶
- Borrowing — the rule itself, and where a borrow actually ends
- Ownership and moves — the model the scaffold is protecting
unwrapis a TODO — the same "compiles quietly, waits" pattern- Implementing
Iterator— the common case where amendment 1 has to be set aside on purpose: a borrowing iterator is a struct holding a reference - quinedot's ownership, borrowing and lifetimes ↗ — the best free treatment when the scaffold comes down
Po polsku¶
Rada, którą początkujący dostaje najczęściej, brzmi: nie używaj referencji, kopiuj i klonuj wszystko, słuchaj kompilatora. To dobra rada podana z lekko złego powodu. Zwykle uzasadnia się ją tym, że „klonowanie jest tanie”. Nie o to chodzi — chodzi o to, że klonowanie usuwa pytanie o czas życia (lifetime), a nie o to, ile kosztuje.
Ta strona dodaje trzy poprawki, z których druga jest najważniejsza dla kogoś uczącego się po polsku, bo dotyczy błędu, którego kompilator nie złapie:
- Naprawdę chodzi o „nie wstawiaj referencji do struktur”. Referencje w argumentach funkcji są zupełnie w porządku i uczysz się ich od pierwszego dnia.
- Klonowanie po to, żeby coś zmienić, to cichy błąd. Program się kompiluje, uruchamia i nie robi nic — bo zmieniasz kopię, którą zaraz wyrzucisz. To jedyny przypadek z tej listy, w którym kompilator ci nie pomoże.
- Słuchaj kompilatora — poza sytuacją, w której podpowiada
'static. To prawie nigdy nie jest właściwa odpowiedź, a jedynie najprostsza, jaką kompilator umie zaproponować.
Brakującym elementem rady jest warunek wyjścia: kiedy przestać klonować. Odpowiedź: gdy zaczynasz klonować w pętli, albo gdy klon trafia do struktury, która i tak żyje krócej niż oryginał.
Szukaj po polsku: czasy życia w Ruscie · klonowanie zamiast referencji · rust lifetimes for beginners · rust 'static lifetime