So, I’m working on writing up an RFC proposing a variant of two-phase borrows. The specific proposal I have in mind is not intended to be a permanent solution. Rather, it is intended to be a narrowly targeted change that addresses the most burning problem (vec.push(vec.len())
will work) but leaves flexibility for the future. I also aim to minimize the impact on the user’s mental model – that is, I wanted to avoid changes that would dramatically affect the set of code that type-checks. This is in tension with the goal of supporting a simple desugaring strategy, and I chose to sacrifice that goal for now.
The proposal is basically a targeted form of two-phase borrowing, where the phases only take effect when users use the method-call syntax. This would be achieved by adding a deferred mutable borrow form into MIR, where the borrowed value is “reserved” until the reference is first used, at which point the borrow is activated (exactly as I described in my blog post). We would only generate these deferred borrows when desugaring method calls into MIR.
Here are the alternatives that I see, and why I landed on this particular proposal:
-
Change the desugaring to evaluate the receiver last in some cases.
- Although I think it can be made backwards compatible if we put enough restrictions, changing the evaluation order introduces inconsistencies into the language. For example, you can execute
x.foo({x = x1; ...})
today iffoo
is afn(self)
method, in which case we evaluate the receiver first (and hence has the original value, notx1
). But if we changex
to be an&mut self
method, it would still compile, but instead we’d see the new value. I find this unfortunate.
- Although I think it can be made backwards compatible if we put enough restrictions, changing the evaluation order introduces inconsistencies into the language. For example, you can execute
-
Defer all mutable borrows, as in the original proposal.
- The original proposal deferred mutable borrows more broadly, but defined the analysis in terms of the MIR. This means that you can desugar
vec.push(...)
intolet tmp0 = &mut vec; ...; Vec::push(tmp0, ...)
, which is nice. However, it also means that users are likely to encounter these “deferred borrows” when doing experimentation and trying to learn the system, and I wasn’t satisfied with the ‘mental model’ that I thought would result. In particular, I think it would be hard to understand where you can take advantage of a borrow being deferred without also understanding how MIR desugaring is done and so forth.
- The original proposal deferred mutable borrows more broadly, but defined the analysis in terms of the MIR. This means that you can desugar
-
Defer a more broadly defined subset of mutable borrows.
- The RFC I plan to submit would defer only those borrows that occur as part of method desugaring. We could defer a bigger subset of mutable borrows: for example, all borrows that result from statements like
let x = &mut ...
orx = &mut ...
. This is probably what the previous proposal would do in practice: we could actually firm that up if we wanted. The deferral would last until the variablex
is next referenced. While I think this is a reasonable and forwards-compatible plan, I felt like I wasn’t sure that this mental model felt “up to snuff” for me. In particular, most of the reasoning about how long a borrow lasts is based on the lifetimes that appear on types, but with this change it is now based also on the path where the reference is stored. This didn’t feel like something I want end-users to see.
- The RFC I plan to submit would defer only those borrows that occur as part of method desugaring. We could defer a bigger subset of mutable borrows: for example, all borrows that result from statements like
-
Extend the type system to understand borrowing for the future, or Ref2.
- After some consideration, I think that it would be best to (eventually) adopt one of these two proposals. (I am not sure which.) They feel to me like the right way to explain what is going on. However, I would rather do this as part of a more general RFC that also introduces non-lexical lifetimes. I would prefer not to block on this more general RFC, but instead allow us to go forward with solving the immediate pain point of nested method calls immediately.
In any case, I think that the plan I am proposing is roughly theintersection of all the above proposals anyhow. In other words, we could choose to go further and adopt a type-system-based solution, or even a desugaring-based solution, without breaking any code.
Thoughts?