What is a good mental model of borrow checker?

@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