This is a write-up of the current state of two-phase borrows, and some potential future changes.
Only certain implicit mutable borrows can be two-phase, any &mut
or ref mut
in the source code is never a two-phase borrow. The cases where we generate a
two-phase borrow are:
- The autoref borrow when calling a method with a mutable reference receiver.
- A mutable reborrow in function arguments.
- The implicit mutable borrow in an overloaded compound assignment operator.
To give some examples:
// In the source code
// Case 1:
let mut v = Vec::new();
v.push(v.len());
let r = &mut Vec::new();
r.push(r.len());
// Case 2:
std::mem::replace(r, vec![1, r.len()]);
// Case 3:
let mut x = std::num::Wrapping(2);
x += x;
// Partially lowered to show the two-phase borrows
// Case 1:
let mut v = Vec::new();
let temp1 = &two_phase v;
let temp2 = v.len();
Vec::push(temp1, temp2);
let r = &mut Vec::new();
let temp3 = &two_phase *r;
let temp4 = r.len();
Vec::push(temp3, temp4);
// Case 2:
let temp5 = &two_phase *r;
let temp6 = vec![1, r.len()];
std::mem::replace(temp5, temp6);
// Case 3:
let mut x = std::num::Wrapping(2);
let temp7 = &two_phase x;
let temp8 = x;
std::ops::AddAssign::add_assign(temp7, temp8);
Note that each two-phase borrow is assigned to a temporary that is used only twice:
- The point where the temporary is assigned to is called the reservation point of the two-phase borrow.
- The other point where the temporary is used, which is effectively always a function call, is called the activation point.
We borrow check two-phase borrows as follows:
- There is only one lifetime associated to the borrow. It is required to outlive the lifetime in the created reference.
- At the reservation point we only error if there are conflicting live mutable borrows.
- Between the reservation and the activation point, the two-phase borrow acts as a shared borrow.
- At the activation point we error if there are any conflicting live borrows.
- After the activation point, the two-phase borrow acts as a mutable borrow.
If the activation point is unreachable, then the lifetime of the two phase borrow ends immediately.
Consider:
use std::{
num::Wrapping,
ops::AddAssign,
};
let mut x = std::num::Wrapping(2);
let y = &mut x;
<Wrapping<usize> as AddAssign>::add_assign(y, *y);
<dyn AddAssign<Wrapping<usize>>>::add_assign(y, *y);
The first call will successfully borrow-check but the second call will not. This is not because of the lack of a two-phase borrow, but because there is an unsizing coercion for the second call. This results in MIR like:
temp1 = &mut *y // Two phase borrow reservation
temp2 = move temp1 as &mut dyn Trait // Activation
Even though the borrow is two-phase, the borrow gets activated before it's possible to observe that it was a two-phase borrow. This is slightly surprising, because there is not visible difference from the users point of view.
There used to be some two-phase borrows involved in matches. They were removed by rust-lang#57609.
These are some alternatives that I could think of for implementing two-phase borrows. With some brief notes on potential problems.
One option might be to evaluate the mutable borrow later: so EXPR1.m(EXPR2) would
- Evaluate EXPR1 to a place, P
- Shared borrow P
- Evaluate EXPR2 as an operand
- Fake read the shared borrow
- Mutably borrow P
- Call
m
This would avoid the need for a two-phase borrow, but it would give incorrect
results if P
was *x
, say, and EXPR2 assigned to x
(unlikely, and
forbidden in AST borrowck, but allowed with NLL).
This weakens two-phase borrows, effectively making them "three-phase". This is easier to model with stacked borrows. Unfortunately, this results in 3 errors when building the compiler, which hasn't had 2PB active for very long.