What is a good mental model of borrow checker?

This is a good question. Ingredients in my mental model:

  • "Physical" intuition about what lives where in memory, and for how long. I'll actually have a vague sense of some buffer floating in space, with various pointers attached to it. This applies mostly to big, long-lived heap allocations. I don't know how to train this intuition.
  • More locally, a sense of what a "good" type signature looks like from the perspective of the borrow check, or rather what the red flags are that suggest a signature is not possible to implement sanely. This is very syntactic or even typographical, there's a little warning sign that starts flashing in my head when I see 'a repeated too many times in a function signature. I think maybe this is the kind of thing you can train by working with the compiler for a long enough time, but you do need a human to explain to you why these patterns are bad at least once.
  • A bunch of rules of thumb that I've internalized enough that I don't have to apply them consciously -- when I'm defining a type with a dynamic string field, I just instinctively reach for String and not &'static str. Reading users.rust-lang.org is good for picking this up, I'd say.
  • "Design pattern"-level knowledge of what kinds of high-level code organization do/don't work well with the borrow check. You can't use references to build a graph, if you have a big "context" singleton you should prefer to pass it explicitly down the call stack instead of stashing it in a global, etc. URLO can be good for this, or you can look at std or high-quality community crates for examples of how to solve particular design problems.

Thinking about the notorious "self-referential struct" as an example, I'm able to avoid that particular trap by a combination of "physical intuition" (picturing the struct moving around in memory and causing the internal pointer to dangle; also, visualizing the ownership hierarchy of my data and noticing when it has a back- or cross-reference), an internalized "rule of thumb", and the habit of using "design patterns" that don't cause me to reach for a self-referential struct in the first place.

Which means I'm not really mentally modeling the borrow check, so much as working in a frame of mind that I've developed based on feedback from the borrow check. As steffahn said,

Except that I have a (very vague) sense of the kind of valid, "normal-looking" programs that the current borrow check sometimes rejects, but that are accepted by polonius. That doesn't come up very often.

3 Likes

It's good to have good documentation, but I don't think it's necessarily a bad sign that lots of people are running into problems with the borrow check and asking questions about it. Fighting the compiler is (anecdotally) a necessary stage in the learning process; asking about a specific problem you're having can be a more efficient/pleasant way to build knowledge than reading docs; and at least on this forum people's borrow-check questions usually get a prompt and thoughtful response.

1 Like

This is the kind of the thing I am looking for, a set of reasoning steps that borrow checker takes, and if I take those steps in my mind I would reach more or less the same conclusion with the borrow checker.

Let me flesh it out. So far, what everybody agrees in this post is that you cannot have two mutable references to the same data. And it is right that you cannot have the following.

fn main(){
    let mut v = vec![0,1];
    let f_ref = &mut v[0];
    let s_ref = &mut v[1];
    
    println!("{f_ref:?}{s_ref:?}")
}

but you can have the following

fn main() {
    let mut v = vec![0,1];
    let (f_ref,s_ref) = v.split_at_mut(1);
    f_ref[0] += 1;
    s_ref[0] += 1;
    
    println!("{f_ref:?}{s_ref:?}");
    println!("{v:?}");
}

in both examples f_ref and s_ref are mutable references to the buffer owned by the same vector.

Aside: (that these references are to the disjoint parts of the buffer is not an excuse they are disjoint also in the case that the borrow rejects, also it does not matter if split_at_mut has (possibly) an unsafe implementation, since unsafe does not turn of the borrow checker. And the possible reply that it should have accepted the first version would not relevant, since this is the reviewer that we have and we should understand what they care about before we criticize what they care about.

So, if I assume that the reasoning step the borrow checker takes is "dont let two mutable references to the same buffer" my mental model of borrow checker is not accurate. It must be enhanced somehow. But how? That is the question.

How should I reason about my code so that I reach to the same conclusion with the borrow checker. Since we are talking about reasoning I would like to hear about the premises and inference rules, but if that sounds too formal, I would be also satisfied with a more discursive presentation.

I can assure you 100% that the standard library compiles and passes borrow checking. Thus, you know that whatever it contains (in safe code) is allowed (and idiomatic, as it is written by the language team itself).

There are huge amounts of questions about all sorts of things on Stack Overflow. Raw question count is not a reasonable measure of how hard or under-documented something is. There is no royal road to geometry. To gain knowledge and experience, you will have to practice.

1 Like

The rule is, &mut cannot be aliased. "Aliased" is not formally defined, but there is a general agreement that aliasing is when two things can access the same memory. &mut [0] and &mut [1] cannot do that.

To show it's definitely not UB, you don't need unsafe for this particular example:

fn main(){
    let mut v = vec![0,1];
    let (f_ref, s_ref): (&mut i32, &mut i32) = match &mut v[..] {
        [one, two] => (one, two),
        _ => unreachable!(),
    };

    println!("{f_ref:?}{s_ref:?}");
}

(However, the fact that split_at_mut is safe to call is also a demonstration that it's sound, despite the use of unsafe, given that std upholds Rust's soundess guarantees. [1])

That's enhancement number 1.

So why can't the first one work? Because Vec's IndexMut implementation is not magical, it takes a &mut self which covers all the elements (and the length field and the capacity field); that API says that any mutable borrow obtained via indexing must borrow the entire Vec.

That's enhancement number 2.


I almost went on to mention some indexing which is magical, but I think I'll emphasize a larger point instead: Rust's complexity around borrowing is fractal, and there's often an exception to the exception and so on. It's also somewhat organic: The slice pattern I used in the code above wasn't available before 1.42. I'm pretty sure you needed unsafe to do the slice borrow splitting before that.

Implicit enhancement number 3: There are sound programs the borrow checker doesn't know how to prove. Sometimes the language evolves to allow more proofs. This is also implicit in the unsafe used for split_at_mut -- it's there because the borrow checker couldn't prove it was sound, even though it is, when implemented correctly.

I don't think knowing exactly how the borrow checker is implemented is not as helpful as a good mental model. Also, the implementation i's shifting all the time in minor ways; you'll probably never keep up with everything.


This fractal nature does make it hard to split the (very large) topic into beginner, intermediate, and advance topics, in my opinion anyway.


  1. Possible side-bar here on unsafe and "turning off the borrow checker' and validity vs safety invariants... ↩︎

4 Likes

@quinedot already gave a good answer, but instead of starting out discussing the “fundamentally forbidden” rules of “aliased &muts” which (at least apparently) neither of the versions of the code actually produce, since the two items are different, let me try to outline the borrow checker point of view, as of my understanding.


One important part of basic knowledge is that borrows form a sort of stack. You can have a &mut T reference, reborrow that for a shorter time to a new &mut T (or maybe &mut S …) reference, re-borrow that one again for even shorter; maybe re-borrow it twice, but not at the same time, etc…

So at any given time, with mutable references alone, there’s a stack of borrows from the original variable down to the currently active usable reference that you’ve derived… and if you thing about a whole time-frame, the access-pattern becomes tree-shaped.

// using non-“standard” indentation to emphasize
// the tree-like structure of (mutable) borrows and re-borrows over time
fn main() {
    let mut x = 0;
    x += 1;
        let r1 = &mut x;
        *r1 += 1;
            let r1_1 = &mut *r1;
            *r1_1 += 1;
        *r1 += 1;
            let r1_2 = &mut *r1;
            *r1_2 += 1; // **** (marked line)
        *r1 += 1;
    x += 1;
        let r2 = &mut x;
        *r2 += 1;
            let r2_1 = &mut *r2;
            *r2_1 += 1;
        *r2 += 1;
            let r2_2 = &mut *r2;
            *r2_2 += 1;
        *r2 += 1;
    x += 1;
    dbg!(x); // -> 13
}

By the way, if at any leaf of this tree, the mutable reference is re-borrowed immutably, then such an immutable re-borrow can alias other immutable re-borrows at the same time, giving a tree-shaped (or… “long non-branching (mutable re-borrows) stem, followed by tree of immutable re-borrows, and re-re-borrows, etc…”) borrow pattern even at a single point in time.

fn main() {
    let mut y = 0;
    let mut x = 0;
    x += 1;
        let r1 = &mut x;
        *r1 += 1;
            let r1_1 = &*r1;
            let r1_2 = &*r1;
            y += *r1_1;
            y += *r1_2;
            y += *r1_1;
            y += *r1_2;
        *r1 += 1;
    x += 1;
    dbg!(x); // -> 4
    dbg!(y); // -> 8
}

But without immutable borrows, there is no branching at any given point. E.g. in the // **** (marked line) above, there is a borrow stack of x being (mutably) borrowed by r1, which is re-borrowed into r1_2, which is then probably even re-borrowed again to create the &mut i32 typed self argumet passed to the AddAssign implementation of i32.[1]

So we have a stack x -> r1 -> r1_2 -> re-borrow of r1_2 at this point of executing the +=, there are no branches. Still there are notably multiple references existing at the same time; just all but the top-of-stack are sort-of inactive / unusable at the time until their respective re-borrow ends; this is how the “only one mutable reference at a time” kind of rule manifests.

Now onto yet another way of branching… even with only mutable references, branches in this re-borrowing stack/tree are possible, in two ways: One of them is via function calls. If you call a fn(&'a mut X) -> (&'a mut Y, &'a mut Z) function, then you turned your one mutable reference into two new ones, and you created a branching at the top of your re-borrowing tree. Their lifetimes are connected (they’re the same; but if the function returned e.g. &'b mut Y with a 'a: 'b bound, the effect would be the same) so the two new references replace the old one in your mental borrow tree. That’s what happens with the split_at_mut. Let’s first blow up

let mut v = vec![0,1];
let (f_ref,s_ref) = v.split_at_mut(1);

into more steps…

let mut v = vec![0,1];
let v_ref = &mut v; // line 2
let v_slice_ref = <Vec<_> as std::ops::DerefMut>::deref_mut(v_ref); // the `&mut self` argument for `split_at_mut` // line 3
let (f_ref, s_ref) = <[_]>::split_at_mut(v_slice_ref, 1); // line 3

borrow stack at/after line 2

v <-mut- v_ref

borrow stack at/after line 3

v <-mut- v_slice_ref

and at/after line 4

v <-mut- f_ref
 ^
 |
 +--mut- s_ref

[2]

So well… now we do have more than one borrow next to each other. But still we cannot create more such borrows directly by accessing v, of course. Both f_ref and s_ref do imply full mutable access over the whole of v as far as the borrow checker is concerned (the information relevant for safety that, and how, they’re non-overlapping is hidden from the borrow checker behind a function boundary), which means as long as either of f_ref or s_ref still exists, you cannot access v, in particular you cannot directly create new references to v using syntax such as &v, until both f_ref and s_ref reach the end of their life-time.


Now a short discussion why

    let mut v = vec![0,1];
    let f_ref = &mut v[0]; // line 2
    let s_ref = &mut v[1]; // line 3

(and then accessing f_ref and s_ref) fails. The borrow stack after line 2 is

v <-mut- f_ref

which comes from passing a mutable references to v to the index_mut operation. (It is by the way a general principle that the borrow checker will work on desugared code, so don’t mind me preferring to talk about an index_mut method rather than indexing syntax. We have a simple fn (&'a mut Vec<T>, usize) -> &'a mut T method for indexing, and reason about it like any ordinary method call.)

Now the next line, line 3, accesses v again. Currently f_ref’s re-borrowing of v prevents that, so we must “kill” f_ref (figuratively) and turn the borrow stack back into

v

before being able to execute line 3 resulting in

v <-mut- s_ref

Since f_ref is “dead” now (figuratively), using f_ref in a print statement will result in a compilation error.


I eluded to these borrows borrowing v completely above. There’s also partial borrows. You’d need to make your borrowing stacks/trees even more elaborate to cover those. E.g. if you have a struct foo, you can create both let ref_a = &mut foo.a and let ref_a = &mut foo.b and use them in parallel. Or even borrow on field mutably and another one immutably. I could imagine you might want to write this as

foo <--mut @.a-- ref_a
   ^
   |
   +---mut @.b-- ref_b

If foo also has a c field, it is still not completely borrowed; for every access to foo while it’s partially borrowed, you would need to consider “what parts of foo am I trying to access in what manner (mutably vs. immutably)?” and “what parts of foo are currently borrowed in what manner (mutably vs. immutably)?” and check that the answers to these questions are not involving mutable access to any already borrowed (mutably or immutably) part, and that they don’t involve any access to any part that is already mutably borrowed. Or you’ll need to “kill” existing borrows appropriately.[3]

In fact, the slice patterns that @quinedot demonstrates seem to give the borrow checker a fairly fine-grained view of what parts of the slice are borrowed by what reference. To the point that the following code does compile!

fn main() {
    let mut v = vec![0,1];
    let slice_ref = &mut v[..];
    let (f_ref, s_ref): (&mut i32, &mut i32) = match slice_ref {
        [one, two] => (one, two),
        _ => unreachable!(),
    };
    
    println!("{f_ref:?}{s_ref:?}"); // point A
    let f_ref2: &mut i32 = match slice_ref {
        [one, _] => one,
        _ => unreachable!(),
    };
    // s_ref is still usable!!
    println!("{f_ref2:?}{s_ref:?}");
}

The borrow stack at point A would need to look something like

v <-mut- slice_ref <-mut @[0]- f_ref
                  ^
                  |
                  +--mut @[1]- s_ref

and killing/ending f_ref makes the index [0] of the slice accessible again for the new f_ref2, resulting in the fact that s_ref is allowed to stay alive throughout the second match expression, where the second entry is not touched at all because it’s matched against a _ pattern.

See… things are getting complex, and more complex, and more complex… which is why no-one will have a complete mental model of the borrow checker (I needed to test the above code to find out whether or not it would be accepted, I didn’t know myself beforehand, but experimenting with the compiler’s reaction to toy examples is a great way of learning more…), and no-one will apply all their knowledge of the borrow checker to all their code either, especially when code just works.

Anyways, to mention even more… I have not gone into (and admittedly also not a particularly detailed mental model of) what happens if you put references into other structs. In fact, we glanced over the fact that the split_at_mut function actually returns a single value (&'a mut i32, &'a mut i32), at which point one could wonder “how does this compound fit into this borrowing-stack?” before we break it up into two separate &'a mut i32 value by pattern matching. To give some negative answers: “how does this compound fit into this borrowing-stack?” can not be answered with “the borrow checker just considers the individual fields”, since the fields of a struct (in this context, I would consider a tuple to be merely struct, too) are an implementation detail[4]. If the tuple was a struct Foo<'a>(&'a mut i32, &'a mut i32) instead, then I’d say: treat the Foo<'a> like any other reference in your re-borrowing tree. But what if there’s two lifetime parameters, Bar<'a, 'b>(&'a mut i32, &'b mut i32). Can we get a borrow-DAG[5]? Probably yes!? But moving a &'a mut i32 field out of Bar<'a, 'b> would re-instantiate the position of that &'a mut i32 in the part of the borrow-tree where the 'a lifetime came from[6]. So maybe Bar<'a, 'b> gets one entry for each of its lifetime parameters… of course a fn foo(&'a mut X, &'b mut Y) -> &'c mut Z function with 'a: 'c and 'b: 'c bounds could now create a single lifetime 'c that does combine two different original lifetime, so we do get a DAG after all, for sure. I guess it’s getting really really complicated now, so let me just stop… :slight_smile:


  1. Well… technically the += operator for i32 is magically compiler-implemented and doesn’t use that trait, but one shouldn’t care about this implementation detail. ↩︎

  2. By the way, one could also argue that passing the intermediate &mut references to deref_mut and split_at_mut would add extra re-borrows. This is still talking about mental models, so either approach should be fine; also I don’t think there’s an easy way to figure out what the borrow checker “actually” does, as it shouldn’t make a difference. With this approach, you’d have the following diagrams:

    borrow stack at/after line 2

    v <-mut- v_ref
    

    borrow stack at/after line 3

    v <-mut- v_ref <-mut- v_slice_ref
    

    and at/after line 4

    v <-mut- v_ref <-mut- v_slice_ref <-mut- f_ref
                                     ^
                                     |
                                     +--mut- s_ref
    
    ↩︎
  3. I mean, there’s two ways to think about borrow-checking conflicts of course: Either, you access a borrowed value in a conflicting way, which kills a borrow, and then the killed borrow is later used again; or you determine that each borrow ends (and gets removed from the borrowing stack) on its own whenever it’s last used, and then the conflicting access is already the error. By the way – sticking with the first approach – killing a borrow would always, of course, also kill all it’s re-borrows to the right in the tree, recursively. ↩︎

  4. and the borrow checker must not consider implementation details, otherwise changing such implementation details would be breaking change, and some properties of these “implementation details” would thus become part of the public API anyways ↩︎

  5. directed acyclic graph ↩︎

  6. I could create a code example to “prove” this if you want ↩︎

3 Likes

If it is not too much to ask I would really like to hear about the magical parts of the Rust that one should be aware when they think about borrows and lifetimes.

I am not sure what you mean by the API of IndexMut, but when I think of API's of functions and methods I think their signatures.

Signature of IndexMut is (&mut T, usize) -> &mut T::Output
Signature of split_at_mut is (&mut T, usize) (&mut T, &mut T)

So if returning a mut ref from another mut ref reborrows entire thing the first ref points to (which must be the case for the API of IndexMut guaranteeing such a behaviour), the second one in its signature does not signal it is doing something different.

This example is again eye opening for the same reason, I would have said that if you pattern match a mut slice what you get (at most) a mut ref to some part and since that mut ref has to borrow the entire scrutinee you cannot get another mut ref to any part of that scrutinee.

So, it seems that some language constructions returning references borrow the entire data, (and I had expected this is the only way) and others like pattern matching manage to borrow parts. Is there again somthing "magical" going on? What are the other parts of the language that can introduce this kind of magic?

So what this amounts to is a difference between the "static model" and the "dynamic model".

If you have fn(_: &'a mut _) -> &'a _, the 'a lifetime means that the input reference is unusable until the returned reference expires and the 'a lifetime is allowed to end.

But it doesn't mean that the return reference actually borrows from the input, just that it's allowed to. As an obvious example,

fn example<'a>(&'a mut i32) -> &'a str {
    let s: &'static str = "hi";
    s
}
error[E0499]: cannot borrow `*v` as mutable more than once at a time
  --> src/main.rs:9:5
   |
8  |     let r = example(&mut *v);
   |                     ------- first mutable borrow occurs here
9  |     &mut *v;
   |     ^^^^^^^ second mutable borrow occurs here
10 |     &*r;
   |     --- first borrow later used here

The output lifetime/reference is tied to the input one; any use of the output reference requires the input reference to be valid.

This applies identically to when multiple output references are produced, e.g. fn(_: &'a mut [T]) -> (&'a mut [T], &'a mut [T]). Nothing about the signature cares about where any of the references actually borrow from; the lifetimes just say where the references are allowed to borrow from.

In this case, both output references have permission to borrow from the input reference. Thus, the input reference is locked until both output references are no longer used again, releasing the lock. Whenever either reference tied to the 'a lifetime is used, it asserts that the lock is still active and valid.

But that doesn't force them to be actually referencing the entire input. Again, I could trivially write a function returning (&mut [], &mut []) with the 'static lifetime, and the signature would still force the caller to deal with the possibility that these references require the lock on the input reference, because the signature says they may.

As far as what they actually reference, it does not matter what the input reference borrows; all that matters is the standard rule that no two active &mut borrows may alias (and that references get permission from somewhere, either from the place they borrow or from a parent reference). Having two &mut to different parts of one allocation breaks no rules, so is allowed.

The relevant one here is borrow splitting.

Given

struct Vec2f {
    pub x: f32,
    pub y: f32,
}

it's perfectly valid to split the borrow, e.g.

let v: &mut Vec2f = …;
let x = &mut v.x;
let y = &mut v.y;
dbg!(x, y);

despite the fact we're using v twice here. There's a few specific cases where primitive indexing is potentially allowed to do borrow splitting the same way, but it's essentially only through pattern matching as shown above, not separate expressions like field borrow splitting.

1 Like

Well... it sort of depends on what you're expanding your knowledge of currently. What I was thinking of in that particular case is that Box, [_], and [_; N] tend to have magical behavior in various ways because various of their operations (Deref, IndexMut, ...) are actually compiler built-ins. You can split borrows through all of those -- for example if you have a Box<(String, i32)> you can mutably deref the box itself twice to get a &mut String and a &mut i32 simultaneously. That's "magic" in that it doesn't correspond to the trait API (the second mutable deref should have "killed" the first one), and you have to just know the quirks of how things got implemented. You can do similar things with slice and array indexing, but not Vec indexing, say.

If you just consider things like the (admittedly underdocumented) mechanics of borrows, reborrows, borrow splitting and so on to be magic, it really depends on what you're expanding your knowledge of, because there is a ton of details and complex interactions. I could list a bunch of advanced [1] behavior but that would probably be counter-productive, in that "beginner, intermediate, or expert?" way, because it would be overwhelming if nothing else. And a lot of it you don't need for day-to-day Rust.

There were a couple things this thread made me think of that were "aha!" moments for me; I'll try to circle back later and mention them in another post in case they're useful for you.


Function signature is what I meant too.

fn<'x>(input: &'x mut T, usize) -> &'x mut T::Output;

"Given something exclusively borrowed for 'x, I'll return an exclusive reborrow also valid for 'x (though incidentally pointing to a different type)."

fn<'x>(input: &'x mut T, usize) -> (&'x mut T, &'x mut T);

"Given something exclusively borrowed for 'x, I'll return two exclusive reborrows also valid for 'x (incidentally pointing to the same type)."

In both cases, input cannot be used until the reborrows (return values) are no longer valid (after 'x). Or phrased differently, the reborrows may only be valid until any of

  • input, or
  • something that input reborrowed from, or
  • (so on transitively), or
  • the owning thing that was exclusivesly borrowed from in the first place

...are used.

They both do create exclusive borrows of their entire data. They both also return a set of reborrows which don't overlap with each other.

You can't call split_at_mut twice on the same &mut slice and use all four returns, only the most recent two. You can call split_at_mut on the returns in a tree-like fashion to create many simultaneously active, but not overlapping, &mut []. You can also call deref_mut and then reborrow or borrow-split (or split_at_mut or ...) the results of that.


Is slice pattern matching magical? Well, it's part of the language and not a trait, so arguably not. But on the other hand, it is borrow check logic you can't apply even with indexing on slices (which is already magic in some ways), so you could also argue that it's another way in which slices are magical I suppose.

Partial borrowing also comes up when you work on separate struct fields, as discussed in the nomicon link.

It doesn't track a set of indices like pattern matching apparently does, but you can borrow split fields over separate expressions through IndexMut<usize>.

(@adal note my uncertainty here -- a bay in the fractal coast I haven't deeply explored myself yet!)


  1. suprising? ↩︎

It absolutely does. It returns two mutable references instead of one. Since mutable references cannot alias, this must necessarily mean that neither could have reborrowed the entire slice (unless it was empty); even though both returned references are derived from the same slice, they are disjoint.

2 Likes

Splitting and disjointedness were largely responsible for reshaping my mental model of borrows. In one fell swoop, the "shape" in my head went from "linked list" to "tree", and that's practically still where it stands today.

A tree rooted in the 2 rules of references, no more and no less:

  1. At any given time, you can have either one mutable exclusive reference or any number of immutable shared references.
  2. References must always be valid.

That's it! That's my whole mental model. It fits in a single tweet. Is it inaccurate? Yeah, you bet. Does the compiler subtly nudge me in the right direction when I'm wrong? All the time! Does it work for me regardless? Indubitably.

5 Likes

The ‘three colors of markers’ are explicit lifetime names

It would be quite useful (at least to me) if we could visualize these markers in our IDEs, that is, if rustanalyzer could produce source code ranges for every lifetime variable in the source.

1 Like

That's harder than it might seem, because the analysis of lifetimes is not based on concrete scopes; rather, a collection of constraints, like "'1 outlives '2" and "'3 must not overlap with '4", are derived from the program, and the borrow checker checks if those constraints are violated. There is no one point in the souce code which is the true end-point of a lifetime; adding more code might cause the apparent scope of a lifetime to shrink (to end before that code makes a conflicting borrow) or to enlarge (to be used in that code). You'd have to choose to display the minimal or maximal ranges, at least.

And lifetimes don't truly have start points at all. A place holding a reference can come to refer to a value which was allocated while the lifetime was valid — so looking at a timeless, scope-only view, the scope of the lifetime is bigger than the scope of the existence of one of the things it referred to.

fn main() {
    let mut x: &str = "hello";
    println!("{x}");               // x is valid here but y does not exist
    let y = "goodbye".to_string();
    x = &y;                        // x refers to y
    println!("{x}");
}

(I don't mean to say that it's impossible, or that these particular display issues are the hardest part of the problem of getting such a tool working.)

3 Likes

@steffahn I really enjoyed reading your detailed explanation of reborrows and borrow splitting. It was exactly kind of thing I am looking for. When someone asks what should I know about the borrow checker I can now say learn about reborrows and borrow splitting and here is a link to steffahn's post that does an excellent job of teaching them.

So are there any thing other than reborrows and borrow splitting that comes to your mind that would make one's picture borrow checker more accurate, when it comes to creating and getting new references?

The reason why I ask the question in that way is because your point

made me realize that so far we only talked about getting or creating references, so in that case we should keep reborrows and splitting in mind. But we have not talk about giving, or consuming references, so when a struct consumes references to initialize its fields etc.

You say

If you stop just because you thought that by getting into these complicated issues you lost our interest, you could not be more wrong. I would very much like to hear what you have to say about references in structs etc.

So to make the question a bit more general, what should one learn about consuming or giving away references that one should be aware of in order to improves one's picture of borrow checker?

I think, references in structs are commonly really straightforwardly intuitively, because in most cases, you'll either have a struct simply (as a container) very directly and transparently containing one or multiple references[1] so you can think about the contained references individually, or commonly if you have opaque structs where you don't know exactly what they consist of, they often (in terms of borrow checking) simply act like a single reference themself[2] so in that case the whole struct simply has a single entrance in your mental re-borrow tree.

I suppose it only gets harder really when you try to come up with a coherent single model that works for all kinds of structs, with arbitrarily many lifetime parameters, all the while respecting privacy boundaries. Such a coherent single model must exist, because the borrow checker can pull it off – e. g. reasoning over structs without thinking about the individual fields, and supporting arbitrarily complicated cases with many lifetimes – but me trying to explain such a single coherent model would be more like me coming up on the spot with something that might work and less like me explaining a preexisting mental model I already have and use. Instead, I think of structs containing references usually like described in the first paragraph above.


  1. as e. g. the tuple case, where can just think of it as two separate values in the first place; or e. g. an array or vector or iterator of references, where the only complication is that it restricts the references to be all of the same lifetime, which simply has the effect that they'll all get the shortest of all lifetimes of the references you put in, I guess ↩︎

  2. e. g. the lock returned by a mutex acts like a mutable reference in many ways ↩︎

2 Likes

These are very good points, certainly lifetimes are not literally "source code location ranges" but this seems to be a good way to think about them, because its visual and relates directly to the lexical structure of the code.

So maybe we should say that: for any valid program, we can assign some set of source code location ranges to each lifetime, such that every assignment respects all relations inferred by the compiler of the form "'1 outlives '2" and "'3 doesn't overlap with '4" for all lexical lifetimes '1, '2, '3, '4.

Finding the precise such assignment which is most useful for visualization is hard. There may never be a "canonical" assignment for some really complex programs. But I would claim that making some such assignment is easy (at least mentally, for a human, maybe much harder for a machine).

As far as actual visualization goes, it seems that visually representing hierarchical regions of source code locations is already well understood (for example, code folding).

As you say, it seems like its certainly possible. So it's really a question of, is this a useful way to think about lifetimes? and are there valid programs where such an assignment cannot be made? I find it useful, but am curious as to what other (perhaps more experienced) Rust users think.

You really do yourself a disservice by forcing lifetimes to map onto any single line of code. There are many trivial cases where this just isn't true, like conditional borrows within a loop. Syntactically, lifetimes are irrelevant. It's all about the liveness of data.

1 Like

Similarly, “two-phase borrows” fundamentally break the syntactic interpretation of borrow lifetimes; it requires reordering evaluation order where two-phase borrows are allowed, but isn't completely describable via the evaluation reordering, because the evaluation order isn't reordered, just the lifetime validity range activation deferred. In the currently proposed Stacked Borrows runtime borrowing/memory model for Rust, two-phase borrows are a special kind of borrow with distinct rules[1] from normal &mut to accommodate this.

“Two-phase” borrowing is what allows you to write things of form place.set(place.get()) (or, technically, even just place.field = place.get() for some fn Place::set(&mut self, _) and fn Place::get(&self, _). Semantically, the lhs place is evaluated first, and then the argument uses the place by-ref for place.get(). Without two-phase borrowing, this invalidates the by-mut evaluation of the lhs place, and the code shouldn't compile. What two-phase borrows allow is for the lhs place evaluation to happen and take a “not yet active mut-reference” to the lhs, then evaluate the arguments, giving them ref-access to the place, only “activating” the lhs mut-access after the arguments are evaluated, so the mut-access lifetime/region doesn't overlap the ref-access required to compute the arguments. The conditions required to make two-phase borrowing allowed are subtle[2]; this falls under a “if the compiler allows it, it's sound” pattern which you'll write without realizing it's a hard case.

The only mental model rules you need to understand what borrowing and lifetimes communicate are roughly the discussed:

  • “simple”
    • & are shared references; until the &s' last use, it is impossible to use the pointee region except by-ref (i.e. it cannot be used by-mut or by-move, but can by-ref or by-copy);
    • &mut are exclusive references; the pointee region which it borrows cannot be accessed by through a parent lifetime while the &mut exists, bit the &mut can temporarily loan access to derived references/lifetimes;
    • If it's safe code and the compiler accepts it, it's sound and you don't need to worry about it.
  • “intermediate”
    • & does not mean immutability of the pointee-reachable; indirection and UnsafeCell can introduce sound by-ref
    • Derived lifetimes from are allowed to borrow the entire parent region, thus static borrowing enforcing all derived lifetimes to expire before accessing a parent &mut, but that's a maximum;
    • When writing unsafe, you need to worry about variance w.r.t. lifetime-erased implementation (e.g. using raw pointers);
    • Just because safe code can do something doesn't imply unsafe APIs can do the same thing, because APIs require the introduction of full references that language features can elide;
    • Pinning and ?Unpin allow breaking the &mut-is-exclusive rule in ways nobody fully understands yet[3];
  • “advanced”
    • These advanced features do not need to be understood except to ask why these safe code snippets are allowed to compile and if/why unsafe function APIs cannot replicate the behavior exactly;
    • Examples include but are not limited to
      • Staggered field splitting, where built-in pure place evaluation (i.e. doesn't create fresh &mut reborrows to call DerefMut or IndexMut) is allowed to reborrow disjoint fields from separate place computations, instead of requiring both come from a pattern match destructure of the same place computation or unsafe place access not creating &mut;
      • Moving out of a pure built-in place evaluation with non-Copy type, creating a deinitialized and inaccessible place, along with moving back into that place reinitializing it and reenabling place access (also handling deferred variable initialization);
      • Two-phase borrows, as described above, enabling place.by_mut(place.by_ref()) for some place types;
    • The compiler getting smarter can allow more patterns in the future, such as NLL did previously and Polonius may in the future.

Everything “advanced” is recognizing the special cases where the “if the compiler allows safe code, it's sound” and whether it's doing magic place (re)evaluation not accessible to surface code. But there's no need to understand any of these features ahead of time, because these have no impact on what function API designs are sound. They do have an impact on what's possible with an API, thus idiomatic/ideal API design (e.g. preferring pub fields instead of trivial getter/setter pairs, to enable borrow splitting), but it's perfectly fine to defer understanding these patterns to when you find yourself asking “why does this compile”. You know already it's sound because it compiles, the advanced principle is being able to describe why it's a sound pattern, and why unsafe function APIs can't soundly replicate the pattern (if they can't).


  1. Actually, I'm not exactly certain how (or even if) the Stacked Borrows model handles two-phase borrows. Miri's implementation of Stacked Borrows has a workaround similar to the described, where it IIRC just doesn't activate the lhs borrow in the Stacked Borrows until the second active phase. This does illustrate that two-phase borrows aren't a completely separate concept; regular one-phase &mut can be semantically treated uniformly with two-phase by just immediately moving from the first “deferred” phase to the “active” phase. There's even been a minor proposal of the dynamic model being “three-phase,” even if it's not possible with safe references, where a with-mut-permission tag starts in a “preshared” phase, where it acts like a shared borrow tag, enters the “active” phase when it's mut-retagged, then if invalidated by ref-access, decays to a “postshared” tag that again acts like a shared tag, and still allows by-ref access to the place. That'd be roughly be

    let r: *mut T = addr_of_mut!(place);
    
    // UB: &mut *r; (formally, no UB yet; it's realized later)
    // `&mut *r` puts `r` in the active state as below
    preshared(&*r);
    
    &place;
    
    active(&mut *r);
    // r moves from preshared to active by mut-access
    // only made valid by two-phase borrows
    // IOW, UB with conservative semantics, including Miri
    // (Miri handles language two-phase, not for pointers)
    postshared(&*r);
    
    &place;
    
    // UB: &mut *r; (realized here)
    // r is put in postshared state by parent shared access
    // mut-access behind r is now UB
    // ref-access made valid by three-phase borrows
    // IOW, UB with conservative semantics, including Miri
    postshared(&*r);
    
    &mut place;
    
    // UB: &*r; (realized here)
    // r is invalidated by parent unique access
    // any access through r is UB
    
    ↩︎
  2. Minimally, the place computation must be transparently pure (e.g. cannot go through a library DerefMut, only naming a variable or built-in derefs (references, pointers, and Box) thereof) after autoref and autoderef, and the place's type must not (perhaps even transitive) contain any shared mutability which could violate the &mut exclusivity expectation that the pointee-reachable must not be modified. ↩︎

  3. The implications of pinning fall into the advanced-itermediate which may become suggested understanding before writing advanced unsafe relying on &mut-exclusivity. It's not worth going into here, as Unpin being safe to implement means the aliasing relaxation cannot be soundly utilized without creating a Pin, so it can be essentially ignored if you never use Pin, and the language has reserved the right to require the use of some new UnsafeMutCell to get &mut-aliasing permission, where PhantomPinned only impacts the safe Unpin trait — i.e. whether Pin<&mut YourType> is allowed to be address-sensitive and can't be relocated by safe code — but not anything further, like aliasing borrow validity when you Pin::get_unchecked_mut(self) -> &mut YourType. ↩︎

2 Likes

Thanks for pointing out borrow splitting

and also two-phase borrows. I really appreciate it!!

You say that

On the contrary I think you have to understand some of it to write good Rust code.

Assume that I haven't read your post and I have no idea about two-phase borrows. So, far my mental model of borrowcheker is that if you take a mutable references to some data it invalidates all the shared references before. So I would think that data.set(data.get()) cannot compile so I would first do let cloned = data.get().clone(); and then pass that cloned value to data.set(cloned). Because of my terrible mental model I did not write a good Rust code and introduced unnecessary clones.

And I think this is not just limited to unnecessary clones. It might affect all my data structure or API design. For example, I know that MVC systems requires that each of the components contain references to each other (or have callbacks that capture references to other units to notify changes) which would introduce reference cycles that Rust would not allow. So, when I design my program I would avoid MVC architecture.

Now that assume that I have a some API design pattern in mind which is clean and I am experienced using it in some other domain, but I mistakenly think that borrowchekcer would not allow it. Then probably I would design my system with unnecessary controtions to satisfy the borrowchecker even though borrowcheker would allow th original design.

So what is the alternative? Just write a piece of code that you would write in other languages without borrowchecker and see whether the borrowchecker is angry? I think this is not feasible. We are giving this advice to Rustaceans, whou would think about software from Rust perspective, using Rust idioms and patterns. The advice here says that be a Pythonista etc, and write your Rust code the way you would in python, then see borrowchecker's complains. Then you can contort your pythonic code to convince the borrowchecker. I think what you would get out of this process would be nothing but a monstrosity.

The issue I am mentioning is not soundness. But elegant, Rustic, code and it cannot be achieved without a sound mental model of the borrow checker.

Thank you for the breakdown of topics to simple, intermediate, advanced, let me try to paraphrase (the ones I manage to understand) for posterity.

Road map to understand borrows and lifetimes

  • beginner
    • shared and mutable references must live at most the lifetime of the referent, and refeneces lock the value they point to i.e once you have a shared reference your interaction with the value must be through it (or you can drop the reference and keep interacting with the value directly) and similarly with mutable references.
    • Reborrows if you have a shared reference you can get new shared references through it. and if you have a mutable references you can get shared or mutable references from it, but the same rules of locking and interaction apply. (i.e if you get a mut ref x from a mut ref y all your interactions with the data must be via x until you drop it)
  • intermediate
    • Interior mutability some types give you the ability to mutate the data even when you only have a shared reference to that type.
    • field/borrow splitting borrowchecker undertsands some compound types that you can have two mut references at the same time to disjoint parts of the compound type.
  • advanced
    • two phase borrows borrowchecker can sometimes reinterpret the order when a mut ref and a shared ref to same data came into being, so that patterns like data.set(data.get() can be allowed.

As you can see my list is smaller than yours because I could not understand some of the points that you made, but I would very much like to.

Hopefully you would agree that having such a roadmap is good for new Rustaceans. Not because it provides soundness for their code but it provides elegegance and Rustic quality to their code.
So, what other thing can I add to this roadmap?

1 Like

OK, I said I would, so here we go.

First, it's been referred to at least indirectly already, but if you haven't already go read Common Rust Lifetime Misconceptions.

Something like this is the general context of this section: a method that borrows mutably but returns a shared borrow (misconception #9).

// With non-elided lifetimes
fn insert_then_get<'a>(&'a mut self, data: T) -> &'a T { /* ... */ }

Sometimes people come around ask why it doesn't work like the article talks about -- letting the method body use a &mut self, but after returning, the object is only immutably (shared) borrowed. And I struggled with this too. The explanation of "well Mutex (a library type) relies on it so we can't change it now" seemed somewhat weak [1]. The more general argument of "&mut is really exclusive access and you can rely on that in your own methods" rang more true [2], but I still struggled to make it gel with my mental model.

So I could explain errors arising from this to others, but it was along the lines of "well, the return sort of carries around the exclusiveness after it gets returned..." [3]

Then eventually, and sadly I can't explain exactly why, I gelled this situation with misconception #8, even though I had understood #8 in isolation for some time: the return being shared or not doesn't matter because in order to call the method at all, the &'a mut must be created, and thus -- statically, unchangeably -- the object is exclusively borrowed for 'a. Within region 'a, you can only access the object with things derived from the &'a mut. That's what creating the &'a mut means. [4] The flavor of the return (shared or exclusive) just doesn't matter in this model.

The return isn't carrying around the exclusiveness; the creation of the reborrow to make the method call in the first place demands the exclusiveness. [5] They're still connected via the lifetime, but once I started thinking of it this way, it was much more clear and "but of course" and I stopped caring about the exact flavor of the return type.

Into the fractal breach...

That wasn't the end of the story though! The adjustment to my mental model really firmed up how I felt about that particular case, but eventually I realized a conflict with my mental model of another borrow functionality: the ability to reborrow an exclusive borrow as shared without killing other existent shared reborrows. Here's an example.

So then I had to spend some time thinking about how the two related, since this sort of re-asks the question: Why can't I call insert_then_get and then turn the borrow of the entire object into a shared borrow? And my eventual answer was, because I've given away the &mut and I can't reborrow it any more. insert_then_get would have to do the reborrow and give it back to me.

It's still an area where I'm fleshing out my ideal model, but as it turns out the different mechanics usually arise in isolation from each other, so truthfully I just use the side of my model that fits any given situation the best.

Because it's never the end of the story.


Related issues come up when people try to explicitly drop references or similar in order to shorten lifetimes. Dropping things actually can make a difference for objects with destructors (as drop scopes are still lexical), but for references, calling drop is just another use site. If the lifetime of a reference was already problematic, adding a use site isn't going to help; your problem is elsewhere.

A reference's lifetime isn't something it's carrying around you can dispose of; it's the output of a static analysis of use sites and liveness.


One last one, though it's arguably more about type than borrows. We like to elide lifetimes on reference types when possible, and refer to "the &str type", and so on. But there is no single type that covers all references &T. Instead, &T is a type constructor: it takes a lifetime, and then &'some_lifetime T is an actual type.

There is no higher-ranked for<'any> &'any T type.

This seems to come up a lot when dealing with generics, because a generic type parameter can only resolve to a concrete type -- and not to a type constructor. So for example here:

fn take_closure<F, I, O>(f: F) where F: Fn(&I) -> O, {
    let _ignored = f;
}
fn example(s: &String) -> &str {
    &**s
}
fn main() {
    take_closure(example);
}

Errors in a pretty poor way:

error[E0308]: mismatched types
 --> src/main.rs:8:5

8 |     take_closure(example);
  |     ^^^^^^^^^^^^^^^^^^^^^ one type is more general than the other
  |
  = note: expected trait `for<'a> <for<'a> fn(&'a String) -> &'a str {example} as FnOnce<(&'a String,)>>`
             found trait `for<'a> <for<'a> fn(&'a String) -> &'a str {example} as FnOnce<(&'a String,)>>`
note: the lifetime requirement is introduced here
 --> src/main.rs:1:51
  |
1 | fn take_closure<F, I, O>(f: F) where F: Fn(&I) -> O, {
  |                                                   ^

The nicest thing here (spoilers) is that it points to the O.

Trying to coerce to a function pointer improves it a bit:

-    take_closure(example);
+    take_closure(example as fn(_) -> _);
8 |     take_closure(example as fn(_) -> _);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ one type is more general than the other
  |
  = note: expected trait `for<'a> Fn<(&'a String,)>`
             found trait `Fn<(&String,)>`
note: the lifetime requirement is introduced here
 --> src/main.rs:1:41
  |
1 | fn take_closure<F, I, O>(f: F) where F: Fn(&I) -> O, {
  |                                         ^^^^^^^^^^^

error: implementation of `FnOnce` is not general enough
 --> src/main.rs:8:5
  |
8 |     take_closure(example as fn(_) -> _);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ implementation of `FnOnce` is not general enough
  |
  = note: `fn(&'2 String) -> &str` must implement `FnOnce<(&'1 String,)>`, for any lifetime `'1`...
  = note: ...but it actually implements `FnOnce<(&'2 String,)>`, for some specific lifetime `'2`

...well, maybe an improvement, it's at least talking about "for any lifetime" versus "some specific lifetime".

Anyway, given how I introduced this section, you may have already figured out the problem: A function that returns a borrow of it's inputs does not return a single type. It returns types that differ in lifetime, based on the input reference's lifetime. The type parameter O doesn't match -- since it represents a single type, the bound can only work for functions that return something unrelated to the input lifetime.

If you know the return is a reference specifically, you can rewrite the bound. Otherwise the workarounds tend to involve traits that allow you to not mention the result as a type parameter, e.g. by using GATs (generic associated types). The original name for GAT was ATC (associated type constructor).

I've mainly seen this for closures, but it happens in other ways when you try to abstract over "borrowing or owned" with generics.


  1. even though I accepted it as a practical barrier to change ↩︎

  2. and I have since made use of this guarantee in my own code for logical correctness! ↩︎

  3. which is still a valid interpretation in many ways! But I found it unsatisfying. Today I'd probably phrase it more like "the &mut gets transformed into the return value". ↩︎

  4. Moreover, you passed the &mut to the method, and &mut isn't copy, ergo you can only access the object with things derived from the return value during 'a. ↩︎

  5. And then you give away the root of that exclusiveness by making the method call. ↩︎

2 Likes