Rule 3 of lifetime elision is the friendliest of the three: if a method takes
&self or &mut self, every elided output lifetime silently becomes the
receiver’s.
It is also the only elision rule that can quietly give you the wrong answer.
Two methods that look identical
pub struct Owner { text: String }
impl Owner {
pub fn text(&self) -> &str { &self.text }
}
pub struct Viewer<'a> { rest: &'a str }
impl<'a> Viewer<'a> {
pub fn peek(&self) -> Option<&str> { self.rest.split_whitespace().next() }
}
Both compile. Both are one line. Rule 3 filled in both return lifetimes from
&self. One of them is right and one of them is a bug, and no test you can
write will tell you which.
The diagnostic question
Does this returned reference point into
self, or throughselfinto somethingselfmerely borrows?
In Owner::text, the String is a field the struct owns. The returned
&str points into memory that dies exactly when the Owner dies. Tying the
result to &self‘s lifetime is not a compromise — it is the literal truth.
Rule 3 is correct here, and naming a lifetime yourself would be noise.
In Viewer::peek, rest is a &'a str — a reference the viewer is holding,
not data it owns. The bytes belong to whatever created the viewer, and they will
still be there long after the viewer is gone. Rule 3’s answer — “the result
lives as long as the borrow of self“ — is sound but under-promising. It
throws away the fact that made the type worth writing.
Always look at the call site
This is the part that makes the bug invisible in unit tests, so it is worth demonstrating rather than asserting. The failure never appears in the method. It appears the first time something tries to drop the receiver:
fn main() {
let s = String::from("a b");
let first;
{
let v = Viewer { rest: &s };
first = v.peek();
} // `v` dropped here
println!("{first:?}");
// error[E0597]: `v` does not live long enough
}
Read that error carefully. The data — s — is alive and well; it outlives the
whole block. The thing that “does not live long enough” is the viewer, which
has nothing to do with the bytes being printed. That is the smell of an
under-promising rule-3 signature: the compiler complains about the wrong
object.
The fix is to name the struct’s lifetime:
impl<'a> Viewer<'a> {
pub fn peek(&self) -> Option<&'a str> { self.rest.split_whitespace().next() }
}
Now &self‘s borrow ends when the call returns, and the result carries 'a —
the lifetime of the text, which outlives everything. The block above compiles.
💡Why does a unit test never catch this? What kind of test would? click to reveal
Because the wrong version is sound. It rejects programs it should accept; it never accepts a program it should reject. Every value it returns is valid. Every assertion on those values passes. There is no input that produces a wrong answer, because there is no wrong answer — only a wrong contract.
The failure mode is not “computes the wrong thing”, it is “cannot be used in a way that ought to work”. That is invisible to any test that only calls the method and inspects the result, which is what unit tests do.
What does catch it is a compile-time test: code that exercises the shape you claim to support and must compile.
const _: () = {
fn _pin<'a>(v: &Viewer<'a>) -> Option<&'a str> { v.peek() }
};
With the elided signature this fails with error[E0621]: explicit lifetime required in the type ofv`. With the‘aversion it compiles. That is the entire test, and it costs nothing at runtime because it *is* nothing at runtime — the wholeconstblock is discarded after type checking. This is why the problems in this track ship thoseconst _:` pins. They are not bureaucracy; they are the only test that can see the property being taught.
“If it compiles, my annotations are correct”
This deserves to be stated as the misconception it is:
The compiler verifies soundness. It never verifies intent.
A signature that compiles has passed exactly one check — that no reference can outlive its referent. It has not been checked for being the signature you meant, the signature your callers need, or the signature that makes your type useful. Elision rule 3 will happily hand you a sound, useless one.
This generalises well past lifetimes, and it is the reason API review is a thing in Rust codebases. “It compiles” is the floor, not the ceiling.
Do not overcorrect
Having been burned once, the temptation is to name lifetimes everywhere. Don’t — that is its own smell, and it is a graded one:
impl Owner {
pub fn text<'a>(&'a self) -> &'a str { &self.text }
}
error: the following explicit lifetimes could be elided: 'a
= note: `-D clippy::needless-lifetimes` implied by `-D warnings`
Since the returned reference genuinely points into self, elision produces
exactly this signature, so writing it out adds nothing but noise.
clippy::needless_lifetimes fails your submission for it — and it is careful
enough to stay silent on Viewer::peek‘s Option<&'a str>, because there
elision would produce something different.
Most methods genuinely should use rule 3. Getters on owning structs, accessors, iterators over owned collections — all correct as elided. The trap is specific: it fires when your struct is a view rather than a container.
💡For each method, decide whether rule 3's elided answer is correct or whether you must name the struct's lifetime. click to reveal
struct Buffer { data: Vec<u8> }
impl Buffer {
fn head(&self) -> &[u8]; // A
}
struct Cursor<'a> { data: &'a [u8], pos: usize }
impl<'a> Cursor<'a> {
fn remaining(&self) -> &[u8]; // B
fn position(&self) -> usize; // C
fn take(&mut self, n: usize) -> &[u8]; // D
}
A — elided is correct. data is owned by the Buffer. The slice points into the buffer and cannot outlive it. Naming a lifetime here trips needless_lifetimes.
B — must be -> &'a [u8]. remaining returns a sub-slice of self.data, which points at somebody else’s bytes. Elided, the result would be borrowed from the cursor and you could not keep it after the cursor goes away — the whole point of a cursor over borrowed data.
C — no lifetime involved at all. usize is Copy and owns nothing. Rule 3 has nothing to fill in. Worth including because the reflex “this is a view type, name everything” is wrong: only reference returns are in scope.
D — must be -> &'a [u8], and this one is the real test. It is &mut self, so elided it would mean “the returned slice borrows the cursor mutably” — which makes it impossible to call take twice and keep both results. Naming 'a decouples them, and the &mut borrow ends when the call returns. This is precisely the zero-copy tokeniser’s next_token, and if you try the elided version you get error[E0499]: cannot borrow ... as mutable more than once at a time at the second call.
The pattern across B and D: the returned reference travels through self to reach the data. That is the tell.
The rule of thumb
When you write a method on a struct that holds references, stop at the return
type and ask the diagnostic question. If the answer is “through self“, name
the struct’s lifetime. If it is “into self“, let elision do it and move on.
And write the const _: pin. It is three lines, it costs nothing, and it is the
only test that can tell you which one you built.