The confused borrowing rules in rust

Hello, I am new to rust.I have so many questions about borrowing during the process of learning Rust. Is there any unified and detailed explanation of borrowing in Rust? Oh, Let me first raise two specific questions:

Env: rustc 1.82.0 (f6e511eec 2024-10-15)


Question 1: Dereferencing vs. Direct Borrowing in Rust

I’m confused about the difference between *&T and T in borrowing. The following code compiles with let b = &mut *a, but fails with let b = &mut x. Why does *a behave differently from x in this context?

Additionally, uncommenting the last line (*b = ...) causes the error:

cannot assign to *a because it is borrowed

fn annoying_borrow() {
    let mut x = Box::new(1);
    let a = &mut x;
    let b = &mut *a;
    // let b = &mut x; // error: cannot borrow `x` as mutable more than once at a time
    *a = Box::new(2);
    // *b = Box::new(3); // error: `*a` is assigned to here but it was already borrowed
}

Does using the *a instead of the x here further imply the subtle difference between them? Emmm, what I really want to explore is not the difference between *a and x, but the rules of borrowing in the Rust type system (compiler).


Question 2: Reborrowing from References

The reborrow_from_imutable fail to compile, while normal_borrow works. The only difference is the presence of an immutable reference r to w. Does reborrowing from an immutable reference (let r0 = &r.0) extend the lifetime of r, preventing mutable access to w.1?

fn normal_borrow() {
    let mut w = (0, 1);
    let r0 = &w.0;
    let m1 = &mut w.1;
    println!("{}", r0);
}

fn reborrow_from_imutable() {
    let mut w = (0, 1);
    let r = &w;
    let r0 = &r.0;
    let m1 = &mut w.1; // error: cannot borrow `w.1` as mutable because it is also borrowed as immutable
    println!("{}", r0);
}

Background:

I’ve referenced Programming Rust, 2nd Ed. (Chapter 5), but I cannot fully comprehend this knowledge:

Shared access is read-only access.

Values borrowed by shared references are read-only. Across the lifetime of a
shared reference, neither its referent, nor anything reachable from that referent,
can be changed by anything. There exist no live mutable references to anything in
that structure, its owner is held read-only, and so on. It’s really frozen.

What's mean of the that structure? Maybe the referent and the anything reachable from the referent?

Mutable access is exclusive access.

A value borrowed by a mutable reference is reachable exclusively via that reference. Across the lifetime of a mutable reference, there is no other usable path to
its referent or to any value reachable from there. The only references whose lifetimes may overlap with a mutable reference are those you borrow from the mutable reference itself.

Since mutable references are exclusive, why is it still allowed to borrow new mutable references from mutable references?

I wonder if there is any unified and detailed explanation of borrowing in Rust. Perhaps there used to be a clear set of rules about borrowing in Rust, but with the compiler continuously relaxing syntax restrictions (to facilitate coding in certain scenarios?), the current rules are more chaotic?

Access to values through exclusive mutable borrows (i.e. the type &mut T) is limited to form essentially an access-stack (where each entry can only borrow from its direct predecessor, and only the top of the stack is usable for direct reads or writes). If you reach the second &mut x line

let mut x = Box::new(1);
let a = &mut x;
let b = &mut x; // <- THIS LINE
*a = Box::new(2);

this sort-of “access stack” currently was looking roughly like

x <-exclusive-borrow-- a

now to access x directly again, for creating b, it needs to remove the unique access/permission that a has on x

x

in order to be able to be able to establish the access to x that b wants to have:

x <-exclusive-borrow-- b

The subsequent usage of a with *a = Box::new(2); then triggers an error, because a is effectively dead.


If it’s instead

let mut x = Box::new(1);
let a = &mut x;
let b = &mut *a; // <- THIS LINE
// …
*a = Box::new(2);

(same previous state)

x <-exclusive-borrow-- a

then b does borrow from a. This is called a reborrow; imagine this access-stack looks roughly like this afterwards:

x <-exclusive-borrow-- a <-exclusive-reborrow-- b

At this point you can now use b (in the section I’ve marked // …), until we finally access a directly again; for this access, the reborrow must be popped

x <-exclusive-borrow-- a

which is why an added *b = Box::new(3); step afterwards would fail.


let mut w = (0, 1);
let r0 = &w.0;
let m1 = &mut w.1; // AN INTERESTING LINE
println!("{}", r0);

this kind of code needs us to extend this stack-shaped model; and we will start introducing the possibility for branching after all. So the stack turns tree-shape (still: each entry can only borrow from its direct predecessor).

Entering shared immutable references (the &T type) into the picture of course produces possible leaves that don’t offer write-access, but that’s not the important part.

The important part is: when can this tree branch? I’ve marked an interesting line on the code. Before that line, we have something like

w <--shared-borrow-- r0

if we wanted to add

w <-+-shared-borrow-- r0
    |
    +-exclusive-borrow-- w

to this picture (without first popping the access for r0), that would be problematic: shared immutable borrows in Rust can’t coexist with exclusive mutable borrows of the same object! But we still lack important detail: the borrow checker also pays attention to certain “places”:

E.g. notice how the error message in reborrow_from_imutable speaks of cannot borrow w.1 as mutable because, that’s talking about w.1 not about w. The second field w.1 is a place, and multiple non-overlapping places can be borrowed simultaneously without conflict. (This is a form of what’s often called splitting borrows.)

Let’s write places into the diagram. r0 only borrows the w.0 field of w:

w <-[@w.0]-shared-borrow-- r0

and the edge we try to add here

w <-+-[@w.0]-shared-borrow-- r0
    |
    +-[@w.1]-exclusive-borrow-- w

targets a different, disjoint place.


Let’s instead consider

let mut w = (0, 1);
let r = &w; // LINE A
let r0 = &r.0; // LINE B
let m1 = &mut w.1; // LINE C
println!("{}", r0);

We start after line A:

w <-[@w]-shared-borrow-- r

this now borrows the full w.

After line B there’s reborrow added[1]

w <-[@w]-shared-borrow-- r <-[@(*r).0]-shared-borrow-- r0

now after line C we would want to add another borrow to w directly:

w <-+-[@w]-shared-borrow-- r <-[@(*r).0]-shared-borrow-- r0
    |    ⚡
    +-[@w.1]-exclusive-borrow-- m1

but the branching now has a shared borrow of all of w next to an exclusive borrow of w.1, which is part of w. This doesn’t work, these two access would conflict :high_voltage: – so instead, we pop the conflicting part, removing the whole access that r and r0 had:

w <-+
    |
    +-[@w.1]-exclusive-borrow-- m1

(let's fix the shape…)

w <-[@w.1]-exclusive-borrow-- m1

using r0 afterwards is an error because is has no access rights anymore.

It is important to note that this model allows no downgrades! We cannot downgrade

w <-[@w]-shared-borrow-- r <-[@(*r).0]-shared-borrow-- r0

to

w <-[@w.0]-shared-borrow-- r <-[@(*r).0]-shared-borrow-- r0

even though the borrow for r0 would – realistically – only require such limited access in the first place. References (&T and &mut T) do always constitute a full borrow of their target anyway!


  1. I’m dropping the “reborrow” working in the graphs here though. Realistically, a reborrow is really just a borrow of the reference target – so the graph from the earlier code example

    x <-exclusive-borrow-- a <-exclusive-reborrow-- b
    

    would just look like this now:

    x <-[@x]-exclusive-borrow-- a <-[@(*a)]-exclusive-borrow-- b
    
    ↩︎
16 Likes

That’s a fantastic explanation. I’m generally okay with the borrow checker, but this made things very clear. Thanks for taking the time to write that up.

2 Likes

Yes, exactly.

I'm sorry this wasn't clear. Figure 5-9 is meant to illustrate it.

As long as the new mutable reference exists, the original can't be used to access the same value.

struct Student {
    name: String,
    gpa: f64,
}

fn modify(student: &mut Student) {
    let r = &mut student.gpa;
    student.gpa -= 1.0;  // error: cannot use `student.gpa` because it was mutably borrowed
    *r += 1.0;
}

For the lifetime of the reborrowed reference r, it has exclusive access to the f64.

Reborrowing is super super important - you wouldn't be able to call string methods on student.name, for example, if the language couldn't reborrow a reference to that field, to pass as the self argument to the method.

1 Like

I'll add my own succinct replies in case they are useful to you.


The exclusive borrow of x[1] is alive through the *a = ... line, and being exclusively borrowed conflicts with taking a new exclusive borrow.[2]

    let a = &mut x;   // --- excl. borrow 1 ----------+
    let b = &mut x;   // attempt at excl. borrow 2    |
    *a = Box::new(2); //                              |
    // -----------------------------------------------+

Same idea, but this case the place which is borrowed is *a instead of x.

    let b = &mut *a;   // --- excl. borrow of *a --------------------+
    *a = Box::new(2);  // overwrite conflicts with being borrowed    |
    *b = Box::new(3);  //                                            |
    // --------------------------------------------------------------+

If you say, overwrite a, *a may not be the same place as x. Sometimes the borrow checker is smart enough to see that x is no longer borrowed when this happens. Example.


Yes, the lifetime of r0 (call it 'r0) and of r (call it 'r) are related ('r: 'r0). Effectively the borrow of all of w (the shared reference r) stays active wherever the shared borrow of (*r).0 (the shared reference r0) is active.


I'm afraid that's just not true in the general case. Mutation can happen to some place reachable by shared reference if there is some form of shared mutability (aka interior mutability) present.[3] For example, a Mutex, an atomic, a Cell or RefCell, ...

(Shared mutability being part of the language is why "shared references" is more accurate than "immutable references".[4])

But if you know all the types and know there is no shared mutability, then it is true that data behind a shared reference is immutable. For example, the bytes you can read from a &[u8] are immutable so long as the reference is valid (at least).

Note that &mut _ is not Copy, as that would conflict with being exclusive. Instead, we have reborrowing.

If reborrowing wasn't possible, this function wouldn't compile...

fn example(vec: &mut Vec<i32>) {
    vec.push(0);
    println!("{vec:?}");
}

...because the call to push would have had to move the &mut Vec<i32>, making it unusable afterwards.

So the glib answer is that the (safe subset of the) language wouldn't be practical to use without reborrowing.

That said, reborrowing is tragically underdocumented.


Here starts the part of my reply where I may ramble on a bit.

Unfortunately not... and it's complex enough I doubt anyone can hold everything in their head at one time.

That said, here's a high-level view of borrow checking which I wish had been presented to me:

  • Every borrow of a place is associated with a type of borrow (shared, exclusive) and a lifetime (those 'a things)
    • Places include variables, fields, and dereferences (so as we've seen, x and *a may be distinct places in a sense, even when a points to x)
  • The compiler uses data flow analysis to calculate where lifetimes (those 'a things) are active[5]
  • Relationships between lifetimes ('a: 'b) can also make them active, for example due to reborrows, lifetime constraints on method calls, implicit limits of nested references, ...
  • The lifetimes along with more data flow analysis is then used to determine where the borrows of places are active
    • This extra layer of abstraction allows a borrow to be shorter than the associated lifetime due to, for example, overwriting a reference
  • Finally, every use of every place is checked against the active borrows of that place to see if there's a conflict
    • E.g. being read conflicts with being exclusive-borrowed but not shared-borrowed; being overwritten conflicts with being borrowed in any way...

Many learning materials try to give the reader the gist about how borrow checking works by talking about scopes -- lexical scopes, and the liveness scope of variables. But despite the name, Rust lifetimes -- those 'a things -- do not directly correspond to the liveness of values. Rust lifetimes are a type-level property that exists at compile time only.[6] The main interaction with scopes and value liveness is that going out of scope and having a non-trivial destructor get called are uses that can conflict with being borrowed (just like any other use can).

(Before NLL, lifetime analysis was much simpler, and lifetimes within a function that spanned multiple statements extended to the end of the containing lexical scope. But NLL landed 7 years ago.)

Yes and no. It's definitely true that the borrow checker has gotten smarter over time to allow more things, and this has resulted in an increasingly fractal "surface" of what passes or fails. That said, I don't think it was ever[7] truly trivial. As one example, you can split borrows through a Box because the dereference of a Box is a compiler built-in operation, but this is not possible for your own DerefMut-implementing types.[8]

I think once most people get the hang of it, they stop trying to understand why allowed things are allowed, and have built up some mental model of why disallowed things are disallowed. Such mental models don't have to be complete in order to be useful. You can start with an approximation of how borrow checking works and refine it over time.


But learning styles and interests differ. If you're interesting in digging into the nitty gritty details of borrow checking, or just want to skim to build up your mental model, here are some resources:


  1. "excl. borrow 1" ↩︎

  2. "excl. borrow 2" ↩︎

  3. UnsafeCell is the primitive source of shared mutability; many OS constructs such as file handles can also be considered to contain shared mutability ↩︎

  4. "mutable" instead of "exclusive" isn't as misleading, but can help explain why creating a &mut _ can cause a borrow check error even if nothing actually gets mutated, for example ↩︎

  5. roughly speaking, if a value with a type containing 'a is alive at a given point, 'a is active at that point ↩︎

  6. Calling it a "lifetime" is an unfortunate overlap of terminology with other uses of the word. ↩︎

  7. I'm ignoring pre-1.0 Rust; I don't know the full history of the borrow checker ↩︎

  8. DerefMut::deref_mut requires an exclusive borrow to all of self ↩︎

4 Likes

Thank you for your patient and detailed answer. These materials look very useful, I will take some time to digest this knowledge. :wink:

Excellent explanation! Thank you very much :grinning_face_with_big_eyes:! Emmm, The model of access-stack and access-tree looks very exquisite, where i can find more details about this?

I do something similar to this and it works well for me, although it's more just intuition than a real model I could describe, and I let the compiler tell me when something won't work. If I'm not fairly sure in advance whether something will compile, I'll first try it in a small case as a test and then decide whether to apply it more broadly.

The difficult thing for me to get over is that Rust is the only language I've used where I've had to use this approach, and that still feels wrong or at least unnatural. With other languages I felt I could learn the rules thoroughly enough to predict whether something would compile or not. Not that everything compiled the first time, but when it didn't I very quickly knew exactly why.

When advocating for Rust I think it's important to admit that for many people this is a real obstacle compared to other languages and to not only point out the advantages of Rust. There is a trade-off, as with all things.

But getting to the advantages, what I get in return is confidence that many logic bugs will be prevented by the compiler due to the "shared xor mutable" approach. And at the same time I can write code safely that is as performant as if it were written in C/C++.

4 Likes

I suppose I did write about (pretty much) this same mental model of borrow checking in this linked topic 2 years ago:

It looks like the exact notation I came up with at the time looked slightly different, but that doesn't really matter - I also touched on a few more corner cases there, e. g. on the interaction of this access stack/tree with calling functions with lifetime signatures.

2 Likes

Hello jorendorff, I am very excited to receive your reply. The all answers in the topic makes me feel that I am not alone in learning Rust.

To better understand how borrowing affects other values ​​in the same ownership tree, I have drawn the memory layout and ownership tree changes of the following code, following the style of the figures in the Programming Rust, 2nd Ed:

struct School {
    history: i32,
    name: String,
    principal: Principal,
}

struct Principal {
    age: i32,
    name: String,
}

fn ownership_tree() {
    // PHASE 1
    let mut cornell = School {
        history: 100,
        name: "Cornell University".to_string(),
        principal: Principal {
            age: 50,
            name: "Martha".to_string(),
        }
    };

    // PHASE 2
    let p_ref = &mut cornell.principal;
    if cornell.principal.age == 50 {} // ERROR: cannot use `cornell.principal.age` because it was mutably borrowed
    if p_ref.age == 50 {} // accessible through reference `p_ref`
    cornell.history = 120; // siblings are not affected

    // PHASE 3
    let p_a_ref = &mut p_ref.age; // auto dereference: &mut (*p_ref).age
    if p_ref.age == 50 {} // ERROR: cannot use `p_ref.age` because it was mutably borrowed
    if *p_a_ref == 50 {} // accessible through reference `p_a_ref`
    p_ref.name = "Martin".to_string(); // siblings are not affected
    *p_a_ref = 60;

    // PHASE 4
    let p_n_ref = &p_ref.name; // auto dereference: & (*p_ref).name
    if p_ref.name == "Martha" {} // downgrade to read-only?
    p_ref.name = "Martin".to_string(); // ERROR: cannot assign to `p_ref.name` because it is borrowed
    *p_a_ref = 70; // siblings are not affected
    if p_n_ref == "Martha" {}

    // PHASE END all dead
}

Using the ownership tree can effectively explain statements and questions in code comments, thank you for providing such a way of understanding 'borrowing' in the book.

I found that thanks to the reborrow mechanism, sometimes multiple mutable borrows (such as p_ref and p_a_ref), mutable borrows and imutable borrows (such as p_ref, p_a_ref and p_n_ref) can coexist. But there are many restrictions:

  • When p_ref and p_a_ref coexist, age can only be accessed (read & write) through p_a_ref;
  • When p_ref, and p_n_ref coexist, the accessiblity of p_ref.name is downgraded to read-only.

These restrictions are reasonable, which is the guarantee of Rust for the security of our program.

Perception:
Through the simulation of the ownership tree, I canunderstand the behavior of the borrow checker well (maybe :)). The access-stack and access-tree model mentioned by @steffahn can also explain the behavior of the borrow checker well.

Both methods are comprehensive and concluding explanations of the behavior of the borrow checker. I don't know which method is closer to the specific implementation of the borrow checker. I don't want to go into the specific implementation of the borrow checker, but I think if I use the two methods mentioned above, I need to think for a long time when writing Rust code why the borrow checker does this, which is a big mental burden.

Is there any way of thinking that can help us avoid errors about borrowing as much as possible when writing code, instead of having to analyze it comprehensively when the borrow checker actually throws an error? What I want to emphasize is writing, not analysis.

Thank you again:
I think I'm a little greedy. I have asked so many questions under this help, and you are not obligated to answer my questions. However, it is precisely because I am very interested in Rust and because I am indeed a little stupid, that I would like to ask you for more knowledge. I am not a freeloader. I have searched for information in books and on the Internet for a long time, but I still don’t have a good understanding of Rust borrowing.

1 Like

This view lingers in my mind:&mut *a => &mut (*a) => &mut x. :smiling_face_with_tear:

Why b does borrow from a?

How about this? The lifetime of r is short than the r0, but r0 does borrow r.

fn broke_access_stack() {
    let w = (0, 0);
    let r0;
    {
        let r = &w; // LINE 1
        r0 = &r.0; // LINE 2
    }
    println!("{r0}"); // LINE 3
}

After LINE 1:

w <-[@w]-shared-borrow-- r

After LINE2:

w <-[@w]-shared-borrow-- r <-[@(*r).0]-shared-borrow-- r0

toward to LINE3:

w <-[@w]-shared-borrow-- r (dead) <-[@(*r).0]-shared-borrow-- r0
   ⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥⛓️‍💥

I think that it is useful, first, to classify common kinds of borrow checking trouble:

  1. The thing you tried to do is actually not possible. This can be a simple use-after-free, or it can be a more subtle thing like the fact that Vec::push() potentially causes existing elements to be moved, so you can't hold a &v[0] reference while pushing another element to v.

    These errors are avoided by being familiar with the signatures and behaviors of the functions you use, and the ways that borrowing interacts with the rest of the language, such as that things must not move while they are being borrowed, and that &mut T implies the T and everything it owns can be moved.

  2. You have written a function that would be correct, except that you put the wrong lifetime annotations on it.

    These errors are avoided by practice, but also by understanding the role of lifetimes. In particular, understand that:

    • Lifetimes do not label how long a value exists, nor do they describe how long a reference exists, but rather they describe how long a borrow exists — an instance of using the & operator. This is a period which is usually shorter than the existence of the value, and often longer than the existence of the reference.

    • Lifetimes are not about a particular borrow, but a set of them that are treated equivalently. When you write fn longer<'a>(x: &'a str, y: &'a str) you're saying that this function will treat x and y as if they are valid for the same lifetime, even if the caller got them at different times and from different sources.

      Comprehending this principle will help you figure out how many lifetimes your function (or struct) needs — avoiding equal lifetimes that should be different, and different lifetimes that should be equal.

      I find that it is a good practice to, when multiple lifetimes are involved, give them names that are appropriate names for the set of things borrowed with this lifetime. For example, if you were writing a parser, you might use a named lifetime like &'input str whenever you borrow from the input, and avoid using that name whenever a borrow is not from the input.

  3. You have written a function that has correct lifetime annotations, but the borrow checker is unable to prove that they are correct. This is rare, but predictable in certain cases, like conditional return of exclusive borrows.

    You just have to learn these cases or be told about them when they come up. Practice will help you notice that this is “that’s funny, this should work”, not “I did something wrong but I don’t know what”.

5 Likes

vec.push(0) => (&mut *vec).push(0), right? Shocking my chin, there's so much information hidden here. :face_with_thermometer:

Thank you, the discussion on lifetime is really impressive. :grinning_face_with_big_eyes:

Yes, this is perfect! You’ve quickly found some limitations of this mental model I’ve presented. :party_popper: [1]

In the other thread I had linked I do touch on this phenomenon somewhat, in a later reply

This would be the relevant section (though it doesn’t quote the code that is being discussed):


Thinking about this today, I can imagine alternative ways to handle this in this mental model. Perhaps we could postulate that these trees modeling out access would always exclusively talk about access to the value at the root itself and nothing else.

This means we can establish as a general rule that any intermediate nodes/sections can be collapsed at any point (which makes sense when the intermediate value they mention goes out of scope).

For your case this means collapsing

w <-[@w]-shared-borrow-- r <-[@(*r).0]-shared-borrow-- r0

down to

w <-[@w]-shared-borrow-- r0

Note that with such a collapse, the place that was borrowed is almost the left-most one; we cannot collapse down to e.g.

w <-[@w.0]-shared-borrow-- r0

because that [deriving @w.0 from the @(*r).0 somehow] would relate places inside of r to places inside of w – and also arguably would be a form of “downgrading borrows”, but the borrow checker doesn’t do this. (IMHO it maybe it should, and perhaps could in the future, but it’s probably hard to design correctly.)


In order to support this as a general rule, we need to make sure it doesn’t make the model too lenient. There are two cases that come to mind:

  • if r itself was a type that has a Drop implementation then access happening within that Drop implementation has the potential to interfere with borrows
    • this is not really a problem in this case because both borrows are shared; but if you had something like
      w <-[@w]-exclusive-borrow-- r <-[@…some_place…]-exclusive-borrow-- s
      
      then a Drop implementation of r’s type could for sure make s invalid
    • to solve this, all places where a value (like r) goes out of scope needs to consider the Drop and for the borrow checker get analyzed as if a &mut-reference (the argument to Drop::drop) was being created
      • except for types that are known not to have any such custom code… there’s a whole mechanism behind this called “drop check” and even some standard library types have custom markers to be more lenient here…
  • if r0 was being created by borrowing r itself directly instead of just some place behind its indirection, then a model where all trees can have their paths collapsed would need to track these things separately
    • this means that if we were to write … = &r; instead of … = &r.0 – in the context of your example, i.e. starting from

      w <-[@w]-shared-borrow-- r
      

      then instead of ending with

      w <-[@w]-shared-borrow-- r <-[@(*r).0]-shared-borrow-- …
      

      we would end with something like

      w <-[@w]-shared-borrow-- r <-[@?]-shared-borrow-- …
      
      r <-[@r]-shared-borrow-- …
      

      instead, establishing r separately as a new root, i.e. some value that owns something that is being borrowed. In reality that something is the memory that contains the reference r itself, i.e. in this case, the pointer whose address value points the the memory location of w.

      I’m not quite sure yet what would be the most appropriate place to note down at the [@?] location; it could either be [@r] too, or we could write [@(*r)] considering that at this interior point in the top w-rooted tree, the outer memory of r itself isn’t really relevant. I would probably still write [@r] there for now, unless I learn of a code example where this choice would make a difference, and where the borrow checker’s actual behavior (rather than this simplified mental model of it) indicates that [@(*r)] is somehow “more correct”.

      To avoid it becoming too large, especially if further access to the right of was to be added, one could probably de-duplicate this tree into something like:

      w <-[@w]-shared-borrow-- r <-[@?]-shared-borrow-+-- …
                                                      |
      r <-[@r]-shared-borrow--------------------------+
      

      so now it’s no longer a tree, but only a DAG (directed acyclic graph)


Even then, this model is probably far from comprehensive yet. I do also hint another important category of examples where it probably fails to model reality (= things already supported by the real borrow-checker) in the very last paragraph of this post starting with:


  1. or at least some points where it isn’t sufficiently clearly defined ↩︎

Great, now I’m not even convinced myself anymore that this works all that well to begin with!

And this it apparently also quite related to the question of modeling when

or more precisely of

because if we do

let r = &w;
let … = &r;

then &r already is a type with two lifetime parameters, &'a &'b (i32, i32).

Put simply, I’m not sure how to best explain – in the context of this general style of model – exactly why the following code still works as well

let w = (0, 0);
let r0;
{
    let r = &w;
    let intermediate = &r;
    r0 = &intermediate.0;
}
println!("{r0}");

but I do strongly suspect that it might have the same answer as the answer to “how to model multiple-lifetime types in general:slight_smile:

1 Like

You're confusing the lexical scope of r with the lifetime ('_ thing) of the borrow -- the lifetime in the type of r. The inner brackets don't change anything in this example. It's a demonstration of how trying to teach borrowing by analogy with scopes can give the wrong impression.

fn broke_access_stack() {
    let w = (0, 0);
    // Let's say this (the *type* of `r0`) has lifetime `'r0`
    let r0;
    {
        // Let's say this (the *type* of `r`) has lifetime `'r`
        let r = &w; // `&w` is a shared borrow of `w` associated with `'r`
        r0 = &r.0;  // This reborrow of `(*r).0` requires that `'r: 'r0`

    } // `r` goes out of scope, but `r` is not borrowed (`w` and `(*r).0` are)
      // so there is no borrow check error

    println!("{r0}"); // This use of `r0` means `'r0` is active here
                      // And because `'r: 'r0`, `'r` is also active here
}

If you remove the inner brackets, the only thing that changes is that r goes out of scope elsewhere. But r going out of scope was effectively a no-op anyway. The only time references going out of scope matters is when the reference itself is borrowed -- when you have something like &r, a reference to a reference.

Or for a simpler demonstration, consider:

fn main() {
    let s: &'static str = "hi";
}

If the lifetime of a reference could not be larger than the lexical scope of the variable, that wouldn't compile.


Yes, there's an implicit reborrow going on there.

I don’t think they were really confusing anything here. In fact their original post is already all about the question of why a re-borrow like &r.0 – or in the OP’s case &mut *a – should have anything to do with the reference r (or a, respectively) in the first place, so I don’t think your explanation that r0 = &r.0 can borrow direclty from wthrough r – is in any way confusing to them.

Their reply was explicitly about the model (of drawing the borrows in a tree/graph) that I had presented, and I stand by my observation that they’ve been point-on in identifying a shortcoming of this model, as it was presented.


Of course I’m still much better at breaking my oversimplified mental models of the borrow-checker myself with way trickier examples, which is why I’ve also brought up

let w = (0, 0);
let r0;
{
    let r = &w;
    let intermediate = &r;
    r0 = &intermediate.0;
}
println!("{r0}");

and then wanted to elaborate an improved version further, until I did – whilst trying to probe some specifics of its behavior in corner cases – reached examples that I can only call “pretty confusing compiler behavior” and lost the motivation of finishing an over-complicated mental model over that.

I suppose you don’t have any clue either as to why

fn test() {
    let x = Box::new(1);
    let y = &(&x, 123);
    let r = &y.1;
    drop(x);
    println!("{r}");
}

vs

fn test() {
    let x = Box::new(1);
    let y: _ = &(&x, 123);
    let r = &y.1;
    drop(x);
    println!("{r}");
}

would make any difference to the borrow checker? :face_with_crossed_out_eyes:

1 Like

In case anyone is interested in a tool to help visualising borrows, rustviz is quite good at that: GitHub - rustviz/rustviz: Interactively Visualizing Ownership and Borrowing for Rust