Consider this code. Why does f1
compile fine but f2
fail? I get that the compiler is complaining about how f.set_x
mutably borrows f
but the f.x() + 1
immmutably borrows f
. Why is the compiler mad about the Deref version then? Is there an easy way to fix this issue besides storing f.x() + 1
into a temporary variable (which is annoying and less readable)?
I suspect the simplistic answer (others may know specifics) is that neither f1 nor f2 should work without special handling in the compiler, since a mutable and immutable borrow cannot be active at the same time. So I'm guessing there is special handling for f1, but not (yet anyway) for f2, since the deref makes this a more complex case. This sort of thing is not uncommon.
One way is to deref prior to the offending expression:
let f = &mut *FooWrapper(Foo { x: 0 });
or add:
let f = &mut *f;
Indeed. This is called a “two-phase borrow”. Some context can be found in the RFC:
2025-nested-method-calls - The Rust RFC Book
The expression
f.set_x(f.x() + 1)
should naively desugar to just
Foo::set_x(&mut f, Foo::x(&f) + 1)
which would evaluate left-to-right
- taking a reference
&mut f
- taking a reference
&f
- passing the latter to
Foo::x
- doing the
+1
- calling
set_x
with the results from 1. and 4.
Step 5. uses the mutable reference from step 1., but step 2. crates a conflicting reference inbetween, which shouldn’t be allowed.
2-phase borrows split up step 1. by only taking a sort of “not quite mutable&unique reference”, and only upgrading it to a full &mut f
reference right before the call to set_x
.
Design decisions as for the extent this feature goes to are detailed in the linked RFC. For example, it’s only used with method syntax at the moment, meaning the “desugaring” I gave above isn’t accurate (the code no longer compiles after it because the two-phase borrow isn’t actually allowed with this syntax).
Unlike this detail however, the limitation that the Deref
-case runs into in this case is more than just an arbitrary design decision; at least unless we want to actually change order of evaluation of expressions. (I.e. in the 2-step process outlined above, the first step of the borrow is still the one that matters at run-time, taking the address for the reference; the second rights-upgrading step is really a no-op.)
The case with Deref
desugars roughly as follows.
Foo::set_x(FooWrapper::deref(&mut f), Foo::x(&f) + 1)
which would evaluate left-to-right
- taking a reference
&mut f
- passing it to
FooWrapper::deref
- taking a reference
&f
- passing it to
Foo::x
- doing the
+1
- calling
set_x
with the results from 2. and 5.
From the compiler’s point of view, the deref
step can involve arbitrary code, which already “sees” a true mutable reference at the time the ::deref
call happens; so we cannot simply have the &mut f
reference still be in a first not-quite-a-&mut Foo
-yet state. I’m not immediately sure what kind of unsoundness problems would lurk behind naively ignoring this problem, but it seems we would probably clearly break some (no-)aliasing guarantees.
Fun fact: if you manually access the field
f.0.set_x(f.x() + 1);
it works again with FooWrapper
.
This is not actually the only example where Deref
operations are more restrictive than e.g. simple field access[1]; conceptually the former suffers from the problem of allowing and encapsulating arbitrary user code [perhaps one could think about some system of marking “well-behaved” Deref
implementations that don’t do much more than a field access – but that’s nontrivial language design issue I suppose].
I won’t go into detail here though ↩︎
To add a tiny bit more context:
IIRC, the original formulation of two-phase borrows was that place.by_mut(place.by_ref())
should be fine since changing the evaluation order to take the &mut
for the method call afterwards should theoretically be an unobservable change to evaluation order, given that place
is a pure place expression. That, and the noalias
semantics in LLVM don't apply until the reference is passed into a function.
Of course, the eventual formulation of two-phased borrows which we ended up with are more involved than that because we don't change the evaluation order and shared mutability results in additional considerations. But the basic rationale is that the receiver reference isn't needed until the method is invoked.