What is the reason why move and assigment have different treament for a (possible) borrowed value?

Consider this example

struct B;
struct C{
	field:B
}
fn main(){
  let mut c = C{field:B};
  let mut mrf: & /* 'a */ mut C = & /* 'a */ mut c;
  let fmrf: & /* 'b */ mut B = & /* 'b */  mut mrf.field;
  mrf; // #1 This results in an error
  *fmrf = B;
}

The code at #1 results in an error, which says

cannot move out of mrf because it is borrowed

However, if we change the code at #1 to

let mut cc = C{field:B};
mrf = & mut cc;

an assignment operation, the code will be compiled. According to the "supporting prefixes" principle, the constraint for lifetime 'b should be 'a : 'b, which requires & mut C to keep valid in the region of 'b. From this perspective, c is borrowed at this point, so we cannot move out or modify c at #1. However, which stuff borrows mrf at #1? If mrf were to be borrowed at #1, not only move operation but also the assignment to it should also be rejected. What is the reason?

The interesting property of the variables at play here is that fmrf does not borrow that reference in mrf and hence the whole variable, directly, as e. g. producing a &mut &mut C would. Instead it reborrows from the value held by mrf (which is a reference itself).

Reborrowing has the (somewhat magical) property that the variable that held the thing you re-borrowed from does not need to keep existing for the whole duration of the re-borrow. It is allowed to go out of scope, or - as you noticed - it can also be assigned a new value and this (somehow) will deassosiate the variable from its originally held borrow.

For this, it's probably necessary not to apply a too strict interpretation of "this variable has this specific type and hence holds this specific lifetime reference all the time" model of thinking, and instead give the borrow checker a bit more lee-way for doing smart inferences, as long as they are still sound :slight_smile:

2 Likes

So, Is this a defect of the borrow checker?

Yes. If you consider any rejected sound program a defect. But I would just call it a shortcoming or a flaw. "defect" is a little bit too strong.

1 Like

A post for this issue to rust-lang is needed.

Simplified:

fn main() {
    let mut c = 1;
    let mut c2 = 2;
    let mut r = &mut c;
    let r2 = &mut *r;
    r; // #error
    // r = &mut c2;   // ok
    *r2 += 1;
}

After think for a bit. The thing is that, use after move is not valid. Suppose your code is changed to this:

struct B;
struct C{
	field:B
}
fn main(){
  let mut c = C{field:B};
  let mut mrf: & /* 'a */ mut C = & /* 'a */ mut c;
  let fmrf: & /* 'b */ mut B = & /* 'b */  mut mrf.field;
  some_move_fn(mrf);
  *fmrf = B;
}

Then some_move_fn can do anything with 'a lifetime, and fmrf would be invalid. But with assignment, the compiler knows it's just being replaced.

The problem here it's just that the compiler did not recognize #1 as a special case of move that in fact does nothing. But in general it's "correct".

I don't see any behavior here that I would consider a "defect".

Assigning to r is like implicitly dropping the old value, whereas r; acts like an explicit drop, drop(r). In the context of values containing invalid or re-borrowed references, it's a common occurrence that implicit drops are allowed, while explicit drops aren't, because an explicit drop is conservatively interpreted as a normal by-value access that could potentially read from the contained invalid or re-borrowed reference.

I'm on mobile right now, so I cannot produce further illustrative code examples for comparison.

1 Like

Apart from the operation(e.g modify, read) on the referent of mrf, I cannot find any reason that modification, moving, or dropping on mrf itself will result in any exception.

Anyway, the diagnosis is a bit misleading, mrf is not borrowed, instead, the value behind mrf is borrowed.

Apparently compiler does not special case reference type in borrow checking (other than reborrow). So dropping does borrow the reference it self.

As contrast, consider this a bit different case(modified from your simplified case)

fn main() {
    let mut c = 1;
    let mut r = & c;
    let r2 = & *r;
    r;  // now it's ok
    let c = *r2;
}

The lifetime constraint for r2 won't have any difference from the original example. In this example, explicitly drop r is ok, merely, r is a shared reference.

That's because r is Copy.

Yes. But we still can use the copy one to do something, as we can use the moved one to do something. I think the difference is & mut T is not copyable, it is absolutely some reason behind move operation.

In my mind, it's more because an immutable re-borrow does not disallow access to the original reference, hence accessing r in the r; statement is allowed.


Although... on second thought, additionally, the Copy-ness of immutable references might be relevant, as well, since it has the effect that r; also only immutable accesses r. I. e. in contrast, if r is still &mut and only the re-borrow r2 was immutable, it probably still fails (haven't tested it yet).

As I noted in the other thread, you can follow the RFC for this example. Assignment being special-cased is to solve Problem Case #4. You can say a drop-like move (a move to nowhere; mrf;) not being special-cased is a "defect" in the compiler, but I'm going to go out on a limb and say this isn't a practical problem many people are running into.

The modification I thought of that is less straight-forward is

struct B;
struct C {
    field: B,
}
fn main() {
    let mut c = C { field: B };
    let mut mrf: & /* 'a */ mut C = & /* 'a */ mut c;
    let fmrf: & /* 'b */ mut B = & /* 'b */  mut mrf.field;
    let mut cc = C { field: B };
    mrf = &mut cc;
    mrf;
    *fmrf = B;
}

This compiles, and we need more than lifetime constraints and shallow writes to explain why. [1] After another skim, it's explained here as part of solving problem case #4: the assignment winnows the list of relevant borrows used later on.


  1. I was mildly surprised as I've seen code which failed borrow check due to a mutable reference always having the same lifetime, i.e. being statically typed. Unfortunately I don't remember enough off-hand to reproduce such an example for comparison. ↩ī¸Ž

2 Likes

Not sure what "we consider borrows relevant if they meet one of the following criteria" exactly means. Does it mean, if one stuff is associated with the loan as specified in the list, then that stuff is considered being borrowed even though we actually borrow from the loan path?

For example:

let fmrf: & mut B = & mut mrf.field;
mrf;

IIUC, in this case, the loan path should be mrf.field where mrf is the supporting prefix of the loan path, right? Then the document says:

Dropping an lvalue LV. Dropping an lvalue can be treated as a DEEP WRITE

In other words, a deep write is a deep access. hence, this rule applies to this case

For deep accesses to the path lvalue, we consider borrows relevant if they meet one of the following criteria:

  • [...]
  • lvalue is a supporting prefix of the loan path

Hence, mrf is considered to be borrowed by fmrf even though fmrf actually borrows mrf.field, right?

Desugared, it's

let fmrf: &mut B = &mut (*mrf).field; // loan ('b, mut, (*mrf).field)

And mrf is a supporting prefix of (*mrf).field.

Then,

mrf;

This is a drop of mrf, and the loan is still active as 'b is still active (and it wasn't killed by an assignment), and this is a deep write; thus the error.


So I'd say "mrf is considered to be borrowed by fmrf" to be a fair statement, yes. So are all its subfields (reachable from mrf), and also in a sense other things connected by the reborrow constraints (and their subfields). Try replacing mrf with any of these (after deriving Copy and Clone for B):

  • &*mrf
  • mrf.field (or (*mrf).field)
  • c
  • c.field
  • &c
  • &c.field
1 Like

If we conclude the such two principle

For shallow accesses to the path lvalue, we consider borrows relevant if they meet one of the following criteria:

For deep accesses to the path lvalue, we consider borrows relevant if they meet one of the following criteria:

It seems to say: If we have a borrowing that borrows from a loan and an lvalue is defined as associated with the loan, then the borrowing is also considered to borrow the lvalue, conceptually.

For example

let fmrf: &'b mut B = &'b mut mrf.field;
// there exists a borrowing: 
// let associated_restriction: &'b (mut <opt>) _ = &'b (mut <opt>) mrf;
mrf; // associated_restriction borrows `mrf` for `'b` so it cannot be moved. 
// however, shallow access is permitted to apply to mrf here
*fmrf = B;

Sounds reasonable.

However,

let fmrf: &'b mut B = &'b mut mrf.field;
// there exists a borrowing: 
// let associated_restriction: &'b (mut <opt>) _ = &'b (mut <opt>) mrf;
mrf; // associated_restriction borrows `mrf` for `'b` so it cannot be moved. 
// however, shallow access is permitted to apply to mrf here
*fmrf = B;

That's a shallow write to *fmrf, not to mrf. You can do deep writes to *fmrf.

  • Is there an existing loan for *fmrf?
    • No, the loans are for c and for (*mrf).field
  • Is there a loan for its prefix fmrf?
    • No
  • Is *fmrf a supporting prefix for a loan?
    • No
2 Likes

// however, shallow access is permitted to apply to mrf here

This comment may be unclear. I meant mrf = & mut cc; can appear at the point where mrf; appears. mrf = & mut cc; is shallow access to mrf.