Skip to content

[nll] move 2-phase borrows into MIR lowering #53198

Closed
@nikomatsakis

Description

@nikomatsakis

@RalfJung and I were talking and we realized that -- with a bit of work -- we could make 2-phase borrows be something fully handled during MIR lowering. This is nice because it means that the rest of the model (e.g., the Unsafe Code Guidelines aliasing model) doesn't have to be aware of it.

There are basically two places we use 2PB today. Let me break down at a high-level how to handle each of them.

Method calls, operators, etc

The first place is for a case like self.push(self.len()). Today, we lower to the push call to:

TMP0 = &mut2 self
TMP1 = lower<self.len()>
push(TMP0, TMP1)

The borrow checker finds the use of TMP0 on the last line and considers that the activation point. In particular, for each 2PB, there must be an exactly one use which is dominated by the borrow and which postdominates the borrow. This use is the activation point.

The idea would be to change to instead lower as:

TMP0 = &mut self
TMP1 = &*TMP0;
TMP2 = lower<self.len() where self=*TMP1>
push(TMP0, TMP2)

Here, we borrow self mutably as normal but then reborrow it to create a shared reference TMP1. Then, when we lower the arguments, any reference to self is rewritten so that it does not refer to self but rather *TMP1 (which has the same type as self). Then the normal NLL mechanisms come into play.

We'd obviously have to tweak our error messages here so that *TMP1 is displayed as "self" to the end-user.

The key observation here is that we can statically see all references to self while lowering the arguments and rewrite them to reference *TMP1 instead. (In fact, we already use a mechanism like this when lowering match guards, see below.)

Matches

We also rely on 2PB to handle variables in match pattern guards. The basic idea of how we lower something like this:

match foo {
  Some(ref mut x) if G => ... 
}

is that we introduce various artificial borrows. The first is a borrow of the place being matched (foo) -- that borrow begins when the match begins and ends upon entering some arm. The second is for bindings that are accessed during match arms. These are each implicitly mapped to a dereference of a shared borrow. So (today!) you wind up with something like:

// Start of match
B0 {
  TMP0 = &foo // the match borrow
  TMP1 = DISCRIMINANT(foo)
  if TMP1 == Some { goto B1 } else { B3 }
}

// We found `foo` is a `Some`
B1 {
  TMP2 = &mut2 foo.as<Some>.0
  TMP3 = &TMP2
  TMP4 = <lower G where x=*TMP3>
  if TMP4 is true { goto B2 }
}

// Corresponds to the `Some` arm:
B2 {
  DROP(TMP0) // borrow of `foo` ends as we enter the arm
  <lower arm body>
}

B3 { ... }

Again, this is what we do today. For this to work, we rely on TMP2 = &mut2 foo.as<Some>.0 being a 2-phase borrow that never gets activated, since otherwise it would overlap with the TMP0 borrow that is definitely still in scope.

Instead of that, we will create a shared borrow and then use some kind of case from &&T to &&mut T:

B1 {
  TMP2 = &foo.as<Some>.0
  TMP3 = &TMP2
  TMP4 = TMP3 as &&mut T
  TMP5 = <lower G where x=*TMP4>
  if TMP5 is true { goto B2 }
}

This cast is provably safe (@RalfJung has done it!), so there is no unsafe code or anything else involved here.

cc @pnkfelix @arielb1

UPDATE: Lightly edited for clarity.

Metadata

Metadata

Assignees

Labels

A-NLLArea: Non-lexical lifetimes (NLL)P-highHigh priorityT-compilerRelevant to the compiler team, which will review and decide on the PR/issue.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions