Multiple borrowing in one statement

Hello there,
As far as I understand, borrow checker adds lifetimes to references to maintain the absence of multiple coexisting (im)mutable references. But I can't understand the following example:

struct A {
}


fn foo(a: &A, b: usize)  {
    
}   
fn bar(a: &mut A) -> usize {
    0
}


fn main() {
    let mut a = A{};
    foo(&a, bar(&mut a))
}

There I get following compile time error:

error[E0502]: cannot borrow `a` as mutable because it is also borrowed as immutable
  --> src/main.rs:16:17
   |
16 |     foo(&a, bar(&mut a))
   |     --- --      ^^^^^^ mutable borrow occurs here
   |     |   |
   |     |   immutable borrow occurs here
   |     immutable borrow later used by call

For more information about this error, try `rustc --explain E0502`.
warning: `playground` (bin "playground") generated 3 warnings
error: could not compile `playground` (bin "playground") due to 1 previous error; 3 warnings emitted

The lifetime of the inner mutable reference is bounded by bar(..) call expression and the lifetime of the immutable reference is bounded by foo(...) call expression.
If we assume that the evaluation order for call expressions is eager then we call bar(...), and then call foo(...), getting that the lifetimes of these references don't overlap in runtime.
So the following example compiles correctly:

struct A {
}


fn foo(a: &A, b: usize)  {
    
}   
fn bar(a: &mut A) -> usize {
    0
}


fn main() {
    let mut a = A{};
    let c = bar(&mut a);
    foo(&a, c)
}

Can you explain, why the first snippet is incorrect?

This is the order of operations:

    let a_ref = &a;
    let a_ref_mut = &mut a;
    let bar_result = bar(a_ref_mut);
    foo(a_ref, bar_result);

I assume you do understand why this doesn't work?

There we have a_ref and a_ref_mut variables that later can be used together and lead to multiple borrows, I understand. But in the original example &mut a and &a can't be used later, because they are parts of one expression

There's still an order of evaluation within an expression. Arguments are executed left-to-right. Swap foo's argument order and you'll see it compiles.

3 Likes

And here's an example where it matters. It doesn't panic with a division by 0 because plus is called before div.

I can't explain the details of the borrow checker's limitations. But I want to point out that while it may be interesting to know exactly what they are, be aware that they are always being improved and there is always ongoing discussion about improving them. So if you're interested in that in depth, you can read the related compiler issues and also participate on the internals forum.

But if you just want to use Rust, the most expedient thing is just to fix the errors and watch the release notes for improvements. And to be aware that the borrow checker is not intelligent per se, it is just using a fixed set of rules, and that set of rules changes over time (for the better). That approach works well for me anyway. If you use Rust a lot you naturally develop an intuition for the limitations and the rules, which helps. But for me at least I've found it just isn't practical to understand the "why" of these rules in depth for every specific scenario.

I mentioned this because this is your first post to forum. Pardon me if you've been using Rust for a while and already know this.

From my point of view &a doesn't need to be evaluated, and bar(&mut a) do need to. Actually, I may be wrong at assuming that correctness of expression is depend on evaluation order, because borrow check is just static check and has nothing common with runtime. So my intuition is that lifetime of &a doesn't overlap with lifetime of bar(&mut a), because they are "independent".

To be more concrete about ordering, we can check MIR representation of the code above and get following:

// MIR for `main` after runtime-post-cleanup

fn main() -> () {
    let mut _0: ();
    let mut _1: A;
    let _2: ();
    let mut _3: &mut A;
    let mut _4: &mut A;
    let mut _5: i32;
    let mut _6: &A;
    let _7: &A;
    scope 1 {
        debug a => _1;
    }

    bb0: {
        StorageLive(_1);
        _1 = A;
        StorageLive(_2);
        StorageLive(_3);
        StorageLive(_4);
        _4 = &mut _1;
        _3 = &mut (*_4);
        StorageLive(_5);
        StorageLive(_6);
        StorageLive(_7);
        _7 = &_1;
        _6 = &(*_7);
        _5 = foo(move _6) -> [return: bb1, unwind continue];
    }

    bb1: {
        StorageDead(_6);
        _2 = bar(move _3, move _5) -> [return: bb2, unwind continue];
    }

    bb2: {
        StorageDead(_5);
        StorageDead(_3);
        StorageDead(_7);
        StorageDead(_4);
        StorageDead(_2);
        _0 = const ();
        StorageDead(_1);
        return;
    }
}

And if I understand correctly this discussion and this explanation of MIR, we have the intersection of lifetime of _6 and _3, that are immutable and mutable respectively. For correct code we don't have so though:

// MIR for `main` after runtime-post-cleanup

fn main() -> () {
    let mut _0: ();
    let mut _1: A;
    let mut _3: &A;
    let _4: &A;
    let _5: ();
    let mut _6: &mut A;
    let mut _7: &mut A;
    let mut _8: i32;
    scope 1 {
        debug a => _1;
        let _2: i32;
        scope 2 {
            debug c => _2;
        }
    }

    bb0: {
        StorageLive(_1);
        _1 = A;
        StorageLive(_2);
        StorageLive(_3);
        StorageLive(_4);
        _4 = &_1;
        _3 = &(*_4);
        _2 = foo(move _3) -> [return: bb1, unwind continue];
    }

    bb1: {
        StorageDead(_3);
        StorageDead(_4);
        StorageLive(_5);
        StorageLive(_6);
        StorageLive(_7);
        _7 = &mut _1;
        _6 = &mut (*_7);
        StorageLive(_8);
        _8 = _2;
        _5 = bar(move _6, move _8) -> [return: bb2, unwind continue];
    }

    bb2: {
        StorageDead(_8);
        StorageDead(_6);
        StorageDead(_7);
        StorageDead(_5);
        _0 = const ();
        StorageDead(_2);
        StorageDead(_1);
        return;
    }
}

I want to use Rust, but I also want to get a precise understanding of borrow checker rules because they are the main driver of safe memory management in Rust. My simple realization was about lifetimes of references, but as far as I understand, it's not so simple and intuitive or the borrow checker actually doesn't work with them. I don't want to dig into rustc-dev-guide
and non-lexical-lifetimes to get it and ask the help with understanding how it works on the concrete example (including more low-level representations if it is possible)

Well, clearly it has to be executed, or you wouldn't have a reference to pass to foo.[1] You just want that to happen later than it does today.

As the plus/div playground illustrates, it would be a breaking change to change the execution order in the general case. So if this was some day accepted, it'd have to be a special case that doesn't change the behavior of current code. I.e. it would have to be limited to certain cases.

That said, we did already get one special case, but it didn't really go smoothly IMO,[2] so I'm not a huge fan generally. But it's a possibility I suppose.

Thinking about what may be non-breaking cases:

// Possible to change this? It doesn't compile so yeah
foo(&a, bar(&mut a));

// Possible to change this?  No, `bar` may change something that
// `new` can observe; in general no calls in the arg0 slot could
// be reordered.
foo(&A::new(), bar( { anything } ));

// Possible to change these?
quz(&a, zot(&a));
quz(&a, { anything });
// If there's no deref coercion in the arg0 slot, I think so.
//
// If there's deref coercion, no, or not if it hits
// an implementation of `Deref` (vs. built-in) anyway;
// that's a call in the arg0 slot

Deref coercion example. (Rust doesn't have a concept of pure functions incidentally.)

The answer to your OP is that the execution is left-to-right, so taking the exclusive reference happens between taking the shared reference and calling the function, resulting in the borrow checker error.

If this thread was about why a method call involving two-phased borrows worked or didn't... the explanation would potentially be a lot more complicated and probably have an asterisk because there's no spec on what is actually implemented to boost confidence in the exact rules.

Special cases to just make this thing work dang it! make the rules more complicated and harder to understand, not easier; if your OP compiled, the explanation of function calls could no longer be "execution is left-to-right". You might have to think about it less,[3] but as I hope the examples have illustrated, you would still have to think about it sometimes.[4]

Sometimes the use cases which more complicated rules enable are worth it, and sometimes they're not. I definitely think NLL was worth it and that location-sensitive Polonius will be worth it too, for example, even though they are large steps up in complexity.


  1. And you can't hide the taking of a shared reference and expect the borrow checker to be able to do its job, which is part of Rust's fundamental safety. ↩︎

  2. implementation allowed more than intended, formal models became a lot more complicated, no spec so there's not even a way to confidently say what's allowed... ↩︎

  3. because more side-effect free code would compile, like your OP ↩︎

  4. both because it wouldn't enable every argument scenario to compile, and also because execution order of argument positions can matter semantically ↩︎

1 Like

Thank you for a long detailed answer, now I can understand a little the explanation with the "execution order". Though it may be not so obviously be rewritten as

let a_ref = &a;
let a_ref_mut = &mut a;
let bar_result = bar(a_ref_mut);
foo(a_ref, bar_result);

because we can choose another way for taking analysis on the call expression (even superposition of the both arguments, who knows), but we should choose the only one to be simple and regular. And standard "left-to-right" is the best choice for that I guess.

1 Like

If you don't want to change the function signature you can do:

let res = (|f: &dyn Fn(_, _) -> _, a, b| f(b, a))(&foo, bar(&mut a), &a);

Not saying you should do that, but it's nice that you can.

Is there an Obfuscated Rust completion like there is for C? If so your example might be a good entry :slight_smile:

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