Vec Reallocation Can't Dangle a Reference in Safe Rust
Calling push on a Vec may allocate a new buffer, copy everything across and free the old one. Any pointer into the old buffer now dangles. In C++ that is a classic bug; in safe Rust it is a compile error, and the reason is more interesting than "the borrow checker said so".
The code that would be a bug elsewhere
Here is the textbook case:
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{first}");
}
In C++ the equivalent might work for months, because the buffer only moves when the capacity runs out. Rust refuses to compile it:
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
Note that the compiler does not know or care whether push would reallocate this time. It rejects the program because it might.
Why the compiler can be that blunt
The rule is not about reallocation at all. push takes &mut self, and a mutable borrow must be exclusive: no other reference into v may be alive while it exists. &v[0] is a shared borrow that is still used by the println!, so the two overlap.
That is the whole mechanism. Reallocation is hidden behind an exclusivity rule, so the type system never needs to model capacity.
The borrow lasts until the last use, not the end of the scope. Move the println! above the push and the same program compiles, because first is dead by then.
Quick detour: why moving the buffer is cheap in Rust
Actually, this bit is interesting. When a Vec grows, Rust moves elements with a plain bitwise copy (or realloc may extend in place). There is no move constructor to run. That works because Rust values are always movable by memcpy, which is also why self-referential structs need Pin and cannot just live in a Vec and expect to be fine.
C++ has to call move or copy constructors on each element, and a throwing one makes growth awkward. Rust skips the problem by forbidding the thing that would care about the address.
What does survive a reallocation
Things that do not point into the buffer are untouched. Back to the example:
- An index:
let i = 0; v.push(4); v[i]is fine, as the index means "slot 0 of whatever the buffer is now". - A cloned or copied value:
let first = v[0];fori32holds no borrow at all. - The
Vecitself: moving theVecmoves three words (pointer, length, capacity), not the heap data.
Indices are the usual escape hatch when you want to keep a "reference" across mutation. The cost is that they can go stale in a different way: remove an element and index 2 now means something else, with no compiler warning.
When you can see the growth
You can watch the buffer move by printing the pointer. Pointers are plain numbers, so reading one is safe; it is dereferencing it that is not.
fn main() {
let mut v: Vec<u32> = Vec::new();
let mut last = v.as_ptr();
for i in 0..40 {
v.push(i);
let now = v.as_ptr();
if now != last {
println!("len {} cap {}: buffer moved", v.len(), v.capacity());
last = now;
}
}
}
You will see a handful of moves, not forty. Growth is amortised: the capacity grows geometrically, so a long run of pushes triggers few reallocations. The exact growth factor is an implementation detail that the docs do not promise, so do not hard-code it.
What is promised: Vec::with_capacity(n) will not reallocate until the length would exceed n. That helps performance, but it does not let safe code keep a reference across a push. The borrow rule applies regardless.
Where it goes wrong: unsafe
Raw pointers are not tracked by the borrow checker, so this compiles:
fn main() {
let mut v = vec![1, 2, 3];
let p = v.as_ptr();
v.push(4);
// Undefined behaviour if push reallocated.
let first = unsafe { *p };
println!("{first}");
}
It may print 1, print garbage, or crash, depending on the allocator. Do not trust "it printed the right thing". If you have to write code like this, run it under Miri (cargo +nightly miri run), which catches use of a pointer into freed memory. It can also complain about the pointer being invalidated by the &mut taken in push even when no reallocation happened, since the aliasing rules it checks are stricter than "did the address change".
The safe fix is to re-derive the pointer after the mutation: call v.as_ptr() again, or better, avoid the pointer and use v[0].
Handing a pointer to C
The one place this bites in practice is FFI. If C code keeps the pointer you gave it, the Vec must not grow while C holds it. Either allocate once with with_capacity and never exceed it, or turn the Vec into a boxed slice with into_boxed_slice, which has no spare capacity to grow into and no push to call.
The borrow checker cannot see across the FFI boundary, so that promise is yours to keep.