Iām working on a project in which Iāve had to work around the borrow checkerās limitations a few times.
I just read Nikoās blog posts on non-lexical lifetimes, and I donāt think I fully understand his solution, but I came up with my own approach to solve this problem in a manner that is, I believe, simple to explain. My approach doesnāt need us to redefine what a ālifetimeā is.
Letās start with a short example:
fn main() {
let data = [1, 2, 3];
let m = &mut data;
let i = &data; // error: cannot borrow `data` as immutable because it is also borrowed as mutable
}
&data
is disallowed because the borrow held by m
is still active. What if the compiler invalidated outstanding borrows when trying to take an incompatible borrow (just like the compiler invalidates variables or struct fields after moving out of them)? In other words, itās as if we moved/dropped the borrowed pointer (even for immutable borrowed pointers, which are Copy
, so we canāt do this explicitly even with a call to std::mem::drop()
).
fn main() {
let mut data = [1, 2, 3];
let m = &mut data;
// invalidate m here
let i = &data;
}
This is valid because we donāt use m
anymore after taking the immutable borrow. Note that Iām not talking about changing the representation of the lifetimes associated with the borrows; instead of raising an error about an existing borrow, taking a borrow would simply invalidate earlier conflicting borrows.
Now, what happens if we try to use m
after taking the immutable borrow?
fn main() {
let mut data = [1, 2, 3];
let m = &mut data;
// invalidate m here
let i = &data;
println!("{:?}", m); // error: m has been invalidated
}
Instead of signalling an error on &data
, weād signal an error on uses of m
after &data
, with a note accompanying the error pointing to &data
(the incompatible borrow) that caused m
(the original borrow) to be invalidated. In other words, weād report an error similar to āuse of moved valueā [E0382].
Open question: If we wrap the let i...
statement in a block, should uses of m
after the block still be disallowed?
fn main() {
let mut data = [1, 2, 3];
let m = &mut data;
{
// invalidate m here
let i = &data;
}
println!("{:?}", m); // is m valid here?
}
Now, what happens if the conflicting borrow is stored in a type that has a destructor?
struct Wrapper<'a>(&'a i32);
impl<'a> Drop for Wrapper<'a> {
fn drop(&mut self) {
println!("{:?}", self.0);
}
}
fn main() {
let mut data = vec![1, 2, 3];
let i = Wrapper(&data[2]);
// invalidate i here
data.clear();
}
What does invalidating m
mean, here? We have to consider when the destructor for i
will run. If it runs at the invalidate i here
comment, then developers will be surprised that variables are no longer always dropped at the end of the enclosing block. If it runs at the end of the block, then we can cause memory unsafety (in the above example, weād try to print data[2] after clearing the vector).
Perhaps the sane way to resolve this is to give an error to force the programmer to write an explicit call to std::mem::drop()
to invalidate i
.
fn main() {
let mut data = vec![1, 2, 3];
let i = Wrapper(&data[2]);
drop(i); // explicitly invalidate i here
data.clear();
}
The idea is to use the same analysis as for moved values (which is based on control flow) to force borrows to be invalidated. Therefore, it would account for ifs, matches, loops, etc.
This is just a theory, I havenāt proven that itās correct, so maybe there are situations in which this solution would either cause unsafety or still be too restrictive?