Variance: Why &mut Makes Lifetimes Rigid
Variance: Why &mut Makes Lifetimes Rigid
Thesis
Most lifetime errors that look like “the borrow checker is being stubborn” are really about variance. Variance is the rule that decides when a type with a longer lifetime can be used where a shorter one is expected. A &'static str can be passed anywhere a &'a str is wanted, but a &mut &'static str can’t be passed where a &mut &'a str is wanted. The reason isn’t arbitrary: if it were allowed, safe code could put a dangling reference into a 'static slot.
This chapter shows the three kinds of variance in compiler output, explains why each one has to be that way, and then works through the most common real bug variance causes: the &'a mut self signature that locks a struct for the rest of its life.
All listings were compiled with rustc 1.99.1, edition 2024. Every error message shown is the compiler’s actual output.
Subtyping, but only for lifetimes
Rust has no struct inheritance, so the only subtyping is between lifetimes. If 'long outlives 'short (written 'long: 'short), then &'long T is a subtype of &'short T, and a value of the first type can be used where the second is expected (Reference: Subtyping and Variance).
Variance describes how that relationship carries through a type constructor F<T>:
- Covariant: if
Ais a subtype ofB, thenF<A>is a subtype ofF<B>. The relationship carries through unchanged. - Invariant:
F<A>andF<B>are unrelated unlessAandBare the same type. - Contravariant: the relationship flips, so
F<B>is a subtype ofF<A>.
Invariant: &mut T can’t shrink its T
Now make the shrinking happen behind a &mut:
// &'a mut T is invariant in T: this is what keeps the next line from compiling.
fn overwrite<T>(slot: &mut T, value: T) { *slot = value; }
fn main() {
let mut s: &'static str = "static text";
{
let local = String::from("short-lived");
overwrite(&mut s, &local);
}
println!("{s}");
}$ rustc --edition 2024 inv_mut.rs && ./inv_mut
error[E0597]: `local` does not live long enough
--> inv_mut.rs:8:27
|
5 | let mut s: &'static str = "static text";
| ------------ type annotation requires that `local` is borrowed for `'static`
6 | {
7 | let local = String::from("short-lived");
| ----- binding `local` declared here
8 | overwrite(&mut s, &local);
| ^^^^^^ borrowed value does not live long enough
9 | }
| - `local` dropped here while still borrowed
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0597`.
&'a mut T is covariant in 'a but invariant in T. Here T is &'static str, so overwrite needs value to have exactly that type, and &local would have to be borrowed for 'static. Suppose &mut &'static str could be treated as &mut &'short str instead. overwrite would store a pointer to local in s, local would be dropped at the closing brace, and println! would read freed memory. Invariance is what prevents that.
The same reasoning covers every type that lets you write through a shared handle. Cell<T>, RefCell<T>, UnsafeCell<T> and *mut T are all invariant in T. A struct’s variance is inferred from its fields, so one Cell field is enough to make the whole struct invariant:
use std::cell::Cell;
// Variance of a struct is inferred from its fields.
struct Plain<'a>(&'a str); // covariant in 'a
struct Shared<'a>(Cell<&'a str>); // invariant in 'a (interior mutability)
fn shorten_plain<'s>(p: Plain<'static>, _scope: &'s str) -> Plain<'s> { p }
fn shorten_shared<'s>(p: Shared<'static>, _scope: &'s str) -> Shared<'s> { p }
fn main() {
let local = String::from("x");
let p = shorten_plain(Plain("static"), &local);
let s = shorten_shared(Shared(Cell::new("static")), &local);
println!("{} {}", p.0, s.0.get());
}$ rustc --edition 2024 structs.rs && ./structs
error: lifetime may not live long enough
--> structs.rs:8:76
|
8 | fn shorten_shared<'s>(p: Shared<'static>, _scope: &'s str) -> Shared<'s> { p }
| -- lifetime `'s` defined here ^ returning this value requires that `'s` must outlive `'static`
|
= note: requirement occurs because of the type `Shared<'_>`, which makes the generic argument `'_` invariant
= note: the struct `Shared<'a>` is invariant over the parameter `'a`
= help: see <https://doc.rust-lang.org/nomicon/subtyping.html> for more information about variance
error: aborting due to 1 previous error
The compiler names the cause directly (“the struct Shared<'a> is invariant over the parameter 'a”) and links to the Rustonomicon. Plain has the same shape without the Cell, so shorten_plain compiles without complaint. When shorten_shared and its call are deleted, the program prints static.
Contravariant: function arguments flip the direction
A function pointer fn(T) is contravariant in T. A callback that accepts a borrow of any lifetime can safely be used where only 'static borrows will ever be passed. The other direction isn’t safe: a callback that requires 'static might store its argument somewhere global.
// fn(T) is contravariant in T: a callback that accepts *any* lifetime
// can be used where one accepting only 'static is expected, not vice versa.
struct Sink<'a> { f: fn(&'a str) }
fn print_it(s: &str) { println!("got {s}"); }
fn widen<'s>(k: Sink<'s>) -> Sink<'static> { k } // OK: contravariant
fn narrow<'s>(k: Sink<'static>, _x: &'s str) -> Sink<'s> { k } // rejected
fn main() {
let k: Sink<'static> = widen(Sink { f: print_it });
(k.f)("hello");
let local = String::from("y");
let _ = narrow(k, &local);
}$ rustc --edition 2024 contra.rs && ./contra
error: lifetime may not live long enough
--> contra.rs:8:60
|
8 | fn narrow<'s>(k: Sink<'static>, _x: &'s str) -> Sink<'s> { k } // rejected
| -- lifetime `'s` defined here ^ returning this value requires that `'s` must outlive `'static`
error: aborting due to 1 previous error
widen (short to 'static) is accepted and narrow ('static to short) is rejected. With narrow removed:
$ rustc --edition 2024 contra_ok.rs && ./contra_ok
got hello
Contravariance is rare in everyday code. In practice it comes from fn(T) fields and from PhantomData<fn(T)> markers.
The common case: &'a mut self on a type with 'a
Here is the variance bug most Rust programmers hit, usually in a parser, interner or arena:
// The "&'a mut Thing<'a>" trap: tying the borrow to the invariant parameter.
struct Interner<'a> { words: Vec<&'a str> }
impl<'a> Interner<'a> {
fn add(&'a mut self, w: &'a str) { self.words.push(w); }
}
fn main() {
let text = String::from("alpha beta");
let mut i = Interner { words: Vec::new() };
for w in text.split(' ') {
i.add(w);
}
println!("{}", i.words.len());
}$ rustc --edition 2024 vec_trap.rs && ./vec_trap
error[E0499]: cannot borrow `i` as mutable more than once at a time
--> vec_trap.rs:12:9
|
12 | i.add(w);
| ^
| |
| `i` was mutably borrowed here in the previous iteration of the loop
| first borrow used here, in later iteration of loop
error[E0502]: cannot borrow `i.words` as immutable because it is also borrowed as mutable
--> vec_trap.rs:14:20
|
12 | i.add(w);
| - mutable borrow occurs here
13 | }
14 | println!("{}", i.words.len());
| ^^^^^^^
| |
| immutable borrow occurs here
| mutable borrow later used here
error: aborting due to 2 previous errors
Some errors have detailed explanations: E0499, E0502.
For more information about an error, try `rustc --explain E0499`.
The signature fn add(&'a mut self, w: &'a str) says: “borrow self mutably for 'a, the same lifetime the struct holds its data for.” Interner<'a> contains Vec<&'a str>, and &mut Interner<'a> is invariant in Interner<'a>. The compiler can’t shrink 'a to make the borrow shorter, so the first call to add borrows i mutably for all of 'a, which is every remaining use of i. The second loop iteration can’t borrow it again, and neither can the println!.
The fix is to stop tying the borrow of self to the data lifetime. The &mut self borrow gets its own short, elided lifetime:
struct Interner<'a> { words: Vec<&'a str> }
impl<'a> Interner<'a> {
fn add(&mut self, w: &'a str) { self.words.push(w); }
}
fn main() {
let text = String::from("alpha beta");
let mut i = Interner { words: Vec::new() };
for w in text.split(' ') {
i.add(w);
}
println!("{} words: {:?}", i.words.len(), i.words);
}$ rustc --edition 2024 vec_fixed.rs && ./vec_fixed
2 words: ["alpha", "beta"]
As a rule: a &'a mut Thing<'a> signature is almost always a mistake. The same goes for &'a mut Vec<&'a T> in function signatures. Give the mutable borrow its own lifetime, which in most cases means leaving it elided.
The variance table
From the Reference:
| Type | Variance in 'a |
Variance in T |
|---|---|---|
&'a T |
covariant | covariant |
&'a mut T |
covariant | invariant |
*const T |
covariant | |
*mut T |
invariant | |
[T], [T; n], Box<T>, Vec<T> |
covariant | |
fn() -> T |
covariant | |
fn(T) -> () |
contravariant | |
UnsafeCell<T>, Cell<T>, RefCell<T> |
invariant | |
PhantomData<T> |
covariant | |
dyn Trait<T> + 'a |
covariant | invariant |
A struct or enum is covariant in a parameter only if every field that uses that parameter is covariant in it. If fields disagree, or any field is invariant, the parameter is invariant. You can’t annotate variance yourself. To change it, you change the fields, often by adding a PhantomData marker of the right shape.
Pitfalls
- Assuming
Cellis “just a wrapper”. Adding aCell<&'a T>field to a struct that used to be covariant makes it invariant. That is a breaking change for downstream code that relied on shrinking lifetimes, even though no public signature changed. - Reaching for
'staticto “fix” invariance errors. WritingT: 'staticor&'staticeverywhere makes the error go away by making the API useless for borrowed data. Find the&mutorCellthat forces invariance instead. - Reading E0499 or E0502 at face value. “Mutably borrowed in the previous iteration of the loop” on a method call usually means the method’s signature ties
&mut selfto a struct lifetime. Check the signature before you restructure the loop. - Unsafe code with the wrong variance. A raw-pointer container that stores
*const Tis covariant, but if it hands out&mut Tit probably needs to be invariant. Variance mistakes inunsafecode are soundness bugs, not just inconvenient compile errors.
Takeaways
- Shared references are covariant, so longer lifetimes shrink to fit.
- Anything you can write through (
&mut T,Cell<T>,*mut T) is invariant inT, because otherwise safe code could store a dangling reference. - Function arguments are contravariant, so callbacks that accept more are acceptable where callbacks that accept less are expected.
- A struct’s variance comes from its fields, so changing a field type can be a breaking change.
&'a mut Thing<'a>locksThingfor its whole lifetime. Let the&mut selfborrow have its own lifetime.
Sources
- The Rust Reference, Subtyping and Variance
- The Rustonomicon, Subtyping and Variance
- Rust standard library,
std::cell::Cell - Rust compiler error index, E0597, E0499, E0502