Struct storing and returning shared vs. mutable references

Heya!

(First post, please be kind)

I am - desperately - trying to understang why Case 1 works

pub struct Thing<'a> {
    data: &'a Data,
}

impl<'a> Thing<'a> {
    pub fn get(&self) -> &'a Data {
        self.data
    }
}

But Case 2 doesn't:

pub struct Thing<'a> {
    data: &'a mut Data,
}

impl<'a> Thing<'a> {
    pub fn get(&mut self) -> &'a mut Data  {
        self.data
    }
}

Using common-sense, my conclusion is as follows

  • Case 1:

    • We know, Thing borrows Data, that must outlive Thing.
    • So it's fine to hand-out shared references that are valid
      • even after Thing goes out of scope
      • but not longer than Data's scope
  • Case 2:

    • Thing holds a mutable borrow of Data.
    • Access to Data must go through Thing.
    • Lifetime 'a must be longer that the anonymous lifetime of &mut self.
    • If that case was allowed, we could hand-out multiple mutable references to Data which is bad

Now I am wondering how Rust thinks about this & accepts Case 1 but comes to the conclusion that Case 2 can cause problems:

22 | impl<'a> Thing<'a> {
   |      -- lifetime `'a` defined here
23 |     pub fn get(&mut self) -> &'a mut Data  {
   |                - let's call the lifetime of this reference `'1`
24 |         self.data
   |         ^^^^^^^^^ method was supposed to return data with lifetime `'a` but it is returning data with lifetime `'1`
  1. I do believe Case 1 is merely solved by the Copy implementation of shared references? So copying a &'a Data gives a &'a Data. Simple enough.
  2. I think to understand that Case 2 error, I first need to understand what self.data is. Is it an (implicit) re-borrow that "somehow" ties the anonymous lifetime of &mut self to &'a mut Data ... but then realizes 'a is not a subtype of the anonymous lifetime?
  3. If 2) was the root-cause of the smartness of the compiler, are there different re-borrow rules for shared and mutable references? Because making Case 1 an explicit re-borrow is still working fine:
pub struct Thing<'a> {
    data: &'a Data,
}

impl<'a> Thing<'a> {
    pub fn get(&self) -> &'a Data {
        &*self.data
    }
}

Any pointers what documentation or reference explains how Rust actually comes to the conclusion that Case 2 is bad would be highly appreciated as well, Thank You!

1 Like

Completely correct.

Copying, reborrowing, and coercion all kind of occur in the same places and it’s hard to say which one is actually happening, but &T: Copy is sufficient to explain this behavior.

The usual way people explain it is that you are in (with un-elided lifetimes)

pub fn get<'b>(&'b mut self) -> &'a mut Data

such that 'a must outlive 'b, and the rule of exclusive reborrowing is that if you have &'b mut &'a mut Data (ignoring Thing since its existence as an intermediate structure doesn't matter to the situation), you can obtain &'b mut Data from it, but not &'a mut Data.

More precisely, whenever you perform an (implicit or explicit) reborrow, the lifetime of the result cannot be longer than the shortest lifetime among the references that were dereferenced to get to the place you are borrowing.

Sadly, nobody’s written a reference chapter on reborrowing rules.

3 Likes

This seems pretty much correct, in particular the part about not allowing multiple active &mut _ at the same time. That would be undefined behavior. In fact, so is an active &mut _ and an active &_: Once the get method returns, the inner &'a mut Data in the Thing<'a> must be the only reference to the referent.

For Case 1, I might say: Thing<'a> already shared-borrows Data for 'a, so it's okay to create more shared-borrows of duration 'a. (Rust lifetimes like 'a generally are the duration of a borrow.)

(@kpreid gave a great answer.)

In that position, yes, it's interpreted as an attempted reborrow, like &mut *(*self).data.[1] The borrow checker rejects it because you can't reborrow ** &'outer mut &'a mut Data as &'a mut Data,[2] only &'outer mut Data (at most).

The rules stated simply are: Start at the inside and go out. The maximum reborrow duration is the duration of the first shared reference, or if there is none, the outer-most reference.

I don't think reborrows are officially documented at all, sadly. But it is described in the NLL RFC:


  1. Otherwise you presumably would have gotten a move error instead. ↩︎

  2. or &'a Data even ↩︎

1 Like

That was very helpful! I was afraid I have to go through the NLL RFC to get to the bottom of it; but also was wondering if it's actually describing how things are implemented today. I'll give it a read.

Thanks much to both of you.

You don't need to read and grok the whole thing to understand why Case 2 errors IMO. The sections on reborrow constraints are accurate in all versions of the borrow checker AFAIK.

On the broader topic of borrow checking, what the RFC describes is not exactly how things are implemented today -- location sensitivity was dropped from NLL due to being too expensive, for example. Problem Case #3 is still with us.

But, there is still a lot of implementation-relevant details and concepts covered in the RFC. It's still useful for building up an explanation of why some things compile, or don't. That said, it's also quite dense and at times subtle. Not really a "read once and aha" type of document (or topic), IMO.

The reference must “go through Thing” accounts for the event that data could relocate due to modification (as in pushing to a vector)? In other words, were it directly to the data the Thing struct could end up pointing to relocated data (UB). Or perhaps I misinterpreted ?

It seems like an issue likely to arise frequently and be puzzling: is there any resource other than the book or the RFC to see some other examples, that experienced users here would recommend to check (and it’s not very advanced) ?


PS: i think this was answered here by @kpreid

More precisely, whenever you perform an (implicit or explicit) reborrow, the lifetime of the result cannot be longer than the shortest lifetime among the references that were dereferenced to get to the place you are borrowing.

but was this only for exclusive references? As it is not the case in the first example that “works” iiuc.

Yes. My statement was incomplete. As @quinedot said: “Start at the inside and go out. The maximum reborrow duration is the duration of the first shared reference, or if there is none, the outer-most reference.” The reason that stopping in this way is sound is that you can copy that shared reference, at which point that copy exists independently of the place you got it from, so nothing about how you got to it matters.

2 Likes

Correct me if I’m wrong. That’s exactly how I interpret the reborrow constraints section in the NLL RFC.

let x: &'x i32 = ...;
let y: &'y i32 = &*x;


In such cases, there is a connection between the lifetime 'y of the borrow and the lifetime 'x of the original reference. In particular, 'x must outlive 'y ('x: 'y). 

This paragraph would explain the mutable case - initial Case 2 - to me… By re-borrowing, you can’t extend the lifetime of a &mut self in a way to return a &’a mut Data .

Until here I was simply accepting the fact that Case 1 - the shared reference case - is just different because shared references are Copy and because that’s “special“ makes the whole thing work.

However, the NLL RFC continues…

In simple cases like this, the relationship is the same regardless of whether the original reference x is a shared (&) or mutable (&mut) reference.

Okay, but ….

However, in more complex cases that involve multiple dereferences, the treatment is different [...] Inituitively, shared references are different because they are Copy – and hence one could always copy the shared reference into a temporary and get an equivalent path.

… this I’d say is the key difference to the initial problem. (Edit: I just realized that @kpreid referenced exactly that sentence from the RFC)

I wasn’t actually dealing with a “simple“ but a multiple-dereferences case, since Case 1 desugars to something like this as you’ve pointed out already:

& *(*self).data

I don’t think I’ve found the “inner to outer“ rule inside the RFC yet but I believe I really have to understand “prefixes“ and some of the other new terminology first.

That's what the (supporting) prefixes are, basically.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.