Definitions
When I say "borrow wrapper", I mean something like std::cell::Ref
, std::cell::RefMut
, or std::sync::MutexGuard
. These are essentially just a struct that wraps a borrow in some way. Usually these types provide Deref
/DerefMut
which allows you to access fields/methods directly, AsRef
/AsMut
to easily expose the underlying borrow (not relevant for this discussion), and sometimes provide a Drop
implementation in order to release the borrow in some way.
Problem
Borrows are special. They can do a lot of cool things that allow them to "play nice" with the borrow checker (IOW, their semantics are restricted enough to allow the borrow checker to make them more flexible). The ones I want to focus on for this discussion are:
- Borrowing multiple fields of a struct - If you have a struct containing two fields, you can mutably borrow both fields through a single mutable borrow of that struct.
- Non-lexical lifetimes - The lifetime of a borrow can end early to allow for future borrows to not conflict.
Borrow wrappers do not get these luxuries.
- Since borrowing a field implicitly calls
DerefMut
, we cannot borrow a second field (since we need another full borrow to the struct). - Since structs are guaranteed to live until the end of the scope (ignoring moves), the borrow lifetime must also extend until then, meaning later borrow wrappers of the same location will conflict.
This makes borrow wrappers annoying to deal with and difficult to understand for new Rust developers. Trying to explain why a reborrow is necessary is painful, and having to explicitly drop a borrow wrapper to end the borrow lifetime is also annoying (since from a user's perspective, it otherwise acts like a borrow).
Proposal
This is actually bundling two proposals to tackle each of these problems separately. I put these into one post since they both solve problems with borrow wrappers.
I've given these solutions names that almost certainly need some bike-shedding, but that sounds like a future problem.
1. DerefMutIdempotent
Today, to borrow multiple fields of a struct at once, you generally need to do a reborrow. So the following (wrong) code:
let mut borrow = ref_cell.borrow_mut();
let field_1 = &mut borrow.field_1;
let field_2 = &mut borrow.field_2;
Can be "fixed" with:
let mut borrow = ref_cell.borrow_mut();
let borrow = &mut *borrow; // ----- NEW
let field_1 = &mut borrow.field_1;
let field_2 = &mut borrow.field_2;
This is semantically different. Previously we were doing two calls to DerefMut::deref_mut
. Now we are doing one. For many borrow wrappers, this is fine. Enter the DerefMutIdempotent
marker trait: this trait says that calling deref_mut
multiple times on the same variable (in a row) makes no changes. This trait could allow the compiler to automatically insert that reborrow for us, since it knows calling deref_mut
twice is equivalent to calling deref_mut
once. Therefore, calling it once before both derefs and then passing the internal borrow to those is safe.
Some key restrictions: this can only be allowed when calling deref
/deref_mut
multiple times in a row (on the same variable). In other words, using the borrow wrapper in any other way will result in that reborrow no longer being used. The wording here is a little confusing, so here's an example:
let mut borrow = ref_cell.borrow_mut();
let field_1 = &mut borrow.field_1;
let field_2 = &mut borrow.field_2;
borrow.map(|x| x.y); // Do something to the borrow wrapper.
let field_3 = &mut borrow.field_3; // This creates a new reborrow.
This just ensures that the reborrow doesn't conflict with borrowing the borrow wrapper itself. Now we can easily borrow multiple fields through a borrow wrapper! Not every borrow wrapper can implement this trait (for example, maybe there's a CountDerefMut
type that just counts whenever deref_mut
is called), but most can, and they will get this sweet behaviour!
I'm not sure if DerefMutIdempotent
needs to be unsafe. Implementing it could lead to wrong behavior, but it's not clear to me whether it can lead to memory unsafety.
I don't believe DerefIdempotent
is necessary for this idea, since if you have a shared borrow and a type were "deref idempotent", there's no problem calling deref
multiple times in a row. No need to do a reborrow (except maybe performance).
A complication with this is the following case:
let mut borrow = ref_cell.borrow_mut();
let field_1 = &borrow.field_1;
let field_2 = &mut borrow.field_2;
*field_2 = field_1;
Since the lifetime of field_1
and field_2
overlap, the compiler needs to create the mutable reborrow before creating field_1
. This makes it kind of weird since the implementation of DerefMutIdempotent
also affects the number of calls to DerefIdempotent
.
Note something like this was discussed before. This post and this post both discuss the possibility of a DerefPure
. This idea is distinct since A) we don't particularly care about deref idempotency (these ideas are ~orthogonal), and B) this does not require DerefMut
to be pure - only idempotent. For example, Bevy includes a Mut
type that wraps a borrow to a component. Whenever you mutate that component, the component is marked as changed (aka dirty). Mutating the component again doesn't change anything! The component is already dirty, so there's no difference.
2. EarlyDrop
Borrows have the magic that is non-lexical lifetimes. This is not the case with borrow wrappers: we must explicitly drop the borrow wrapper (or put them in a scope):
let borrow = ref_cell.borrow_mut();
drop(borrow);
let borrow = ref_cell.borrow_mut();
drop(borrow);
let borrow = ref_cell.borrow_mut();
// OR
{ let borrow = ref_cell.borrow_mut(); }
{ let borrow = ref_cell.borrow_mut(); }
{ let borrow = ref_cell.borrow_mut(); }
Ideally we can replicate the result of non-lexical lifetimes for any arbitrary struct. Enter the EarlyDrop
marker trait: this marks that a type should be dropped immediately after its last use (last use meaning its last borrow's lifetime expires). Now we can do all sorts of things like:
let borrow = ref_cell.borrow_mut();
let borrow = ref_cell.borrow_mut();
let borrow = ref_cell.borrow_mut();
Is that example cursed? Yes. Does it act like a borrow though? Also, yes! Because std::cell::RefMut
would implement EarlyDrop
, each of these borrows gets immediately dropped (since they have no other uses). Borrows also do this. If you don't use them, their lifetime just ends. This also allows examples like:
let mut borrow = ref_cell.borrow_mut();
borrow.field_1 = 7;
// Do some other stuff here.
let mut borrow = ref_cell.borrow_mut();
borrow.field_2 = 21;
// Do some other stuff here.
let mut borrow = ref_cell.borrow_mut();
borrow.field_3 = 1337;
We didn't need to explicitly drop, or add any "unnecessary" scopes!
Not every type should implement this trait: namely, MutexGuard
should almost certainly not implement this trait. The length of a critical section is extremely important.
Again, I'm not sure if EarlyDrop
needs to be unsafe. Since EarlyDrop
doesn't "kick in" until the last borrow's lifetime expires, all the "valid" lifetimes are accounted for. The only other lifetimes to consider AFAIK are from borrows that were unsafely created, in which case its unsafe block should "protect" it.
Risks
I don't know how feasible these are implementation-wise (I'm not a compiler contributor... yet). I suspect all the parts exist, though how easy it is to bring them together I'm not sure.
DerefMutIdempotent
- The compiler already inserts reborrows automatically in certain situations.
EarlyDrop
- You can already drop a variable early (moving it, or
let _ = ...
) - We can track borrows to a variable and when those lifetimes end (since we know when they overlap).
- Borrows sort of already do this
- You can already drop a variable early (moving it, or