Making "borrow wrappers" more like borrows

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:

  1. 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.
  2. 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.

  1. Since borrowing a field implicitly calls DerefMut, we cannot borrow a second field (since we need another full borrow to the struct).
  2. 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.

  1. DerefMutIdempotent
    • The compiler already inserts reborrows automatically in certain situations.
  2. 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
1 Like

Another disadvantage of this is that codegen will depend on the exact lifetimes inferred by borrowck. Currently you erase all lifetimes (using 'static or the unstable 'unsafe) in unsafe code without causing problems, but with EarlyDrop that may result in a use-after-free if borrowck thought the last use of a variable is earlier than it actually is.

2 Likes

What about enabling early drops only for types that can be safely dropped earlier? The programmer must guarantee that nothing goes wrong, so it should be an unsafe trait.

I mean the trouble here happens only in types whose drop actually do something interesting like deallocating memory. But a type like &T can effectively be early dropped because it doesn't even have a drop glue, so dropping is a no-op anyway. And other types that have a drop glue but it doesn't do anything too bad, it could be safe to drop earlier.

Calling the drop impl before the last value would only be sound if the drop impl doesn't invalidate the safety invariant of the value that gets dropped. So that excludes the drop impl for Box (deallocation would allow use-after-free), RefCell's Ref/RefMut and skmilar for Mutex and RwLock (would allow getting two mutable references to the same value) Pretty much the only cases where it would be sound is if the Drop impl only updates some statistics or such. Or indeed if there is no drop glue in the first place.

1 Like

DerefMutIdempotent would be fine from a soundness standpoint though. It would almost certainly apply during lowering from HIR to MIR and thus participate in borrowck as expected.

1 Like

Currently you erase all lifetimes (using 'static or the unstable 'unsafe) in unsafe code without causing problems, but with EarlyDrop that may result in a use-after-free...

Yes, this was something I was a little worried about. Technically we could say that the unsafe code should be guaranteeing that the lifetime hasn't expired (which includes considering EarlyDrop). Alternatively, we could make EarlyDrop unsafe to make it clear that using it could cause issues for some unsafe code. But that leads to the next issue:

Calling the drop impl before the last value would only be sound if the drop impl doesn't invalidate the safety invariant of the value that gets dropped.

I don't understand what this means. What do you mean by "last value"? Isn't this the last borrow's lifetime? Unless you're referring to unsafe code, in which case I argue that your unsafe block isn't guaranteeing the objects lifetime. To be clear, EarlyDrop would likely need to occur over an edition boundary since it can cause unsafe code to be unsound.

I meant last use of the value.

Yes, I'm talking about unsafe code. There are cases where unsafe code is forced to lie about the lifetime of a reference as rust simply doesn't have any way to indicate the actual lifetime. For example for self-referential types the borrow pretty much has to get a fake 'static lifetime as self-referential types don't have a lifetime argument. Also if you have a variable and take a raw pointer rather than a reference to it, borrowck wouldn't know how long it has to extend the lifetime of the variable as raw pointers don't have lifetimes.

1 Like