Why am I getting a double mutable borrows error?

AFAICT foo is only ever borrowed once here

struct Foo;

async fn future(_: &mut Foo) {}

fn main() {
    let mut foo = Foo;

    let mut foo_future = None;

    loop {
        match foo_future {
            Some(_) => foo_future = None,

            None => foo_future = Some(future(&mut foo)),
        }
    }
}

Playground.

No, it is borrowed once per iteration. If you remove the loop it compiles. The error message contains a useful hint in this regard by the way:

error[E0499]: cannot borrow `foo` as mutable more than once at a time
  --> src/main.rs:14:46
   |
11 |         match foo_future {
   |               ---------- first borrow used here, in later iteration of loop
...
14 |             None => foo_future = Some(future(&mut foo)),
   |                                              ^^^^^^^^ `foo` was mutably borrowed here in the previous iteration of the loop

For more information about this error, try `rustc --explain E0499`.
error: could not compile `playground` (bin "playground") due to previous error

telling you that `foo` was mutably borrowed here in the previous iteration of the loop

There's some very slight overlap the borrow checker perceives. You pass the new mutable borrow to future(…), and only after that comes the assignment to foo_future that drops the future holding the previous borrow, in the view of the borrow checker, which doesn't care about the fact that the match statement identified foo_future to contain None and thus no borrows that could be accessed on drop anyway.

(The borrow checker also doesn't provide any more sophisticated analysis that could determine that foo_future can only become Some once in your code.)

What does work to convince the borrow checker is to make an explicit, slightly earlier drop of foo_future, before the “next” call to future(…):

None => {
    drop(foo_future);
    foo_future = Some(future(&mut foo));
}
5 Likes

Thanks, I also thought this was the problem and I tried testing it by setting foo_future = None; instead of dropping the foo_future, which didn't work.

foo is only borrowed inside the Some(..) variant, so how is that not the same?

1 Like

Underlying this is that the borrow checker is not perfect, and is designed to only accept code that it's confident is correct, not code that it can't prove faulty.

I'm mildly abusing Rust's lifetime bounds syntax below to also represent concrete lifetimes: this is not how Rust actually deals with things, but it's a reasonable intuition for what's really going on.

From the borrow checker's limited point of view, foo_future is of type Option<&'life mut Foo>, and it's not aware that when foo_future's value is Option<&'life mut Foo>::None, the lifetime is irrelevant; instead, it observes that 'life doesn't change until the foo_future = Some(&mut foo), where it gets a new borrow to introduce a new lifetime.

When @steffahn explicitly wrote drop(foo_future);, that was recognised by the borrow checker as "forget everything you know about foo_future, it's gone now", allowing it to recognise that the value of 'life in the type has changed. When you wrote foo_future = None, the borrow checker did not realise that this carries with it the implication that 'life might be taking on a different value now.

This particular case is one that could be fixed in the borrow checker; it could learn that when an enum value changes to a variant that contains no lifetime parameters (such as Option<&'_ mut T>::None), the lifetime in the type must have changed, just as it already knows that when it switches to a variant containing a lifetime parameter, there's a lifetime change (as in Option<&'_ mut T>::Some(&mut xxx), where the &mut xxx indicates the start of a new lifetime region). But this requires someone with motivation and ability to look into the borrow checker and work out why it's not spotting this case already, then fixing it to do the right thing.

1 Like

Interesting observation that foo_future = None doesn't help. Perhaps it's related to the observation that such an assignment could also work for variables we only have access through mutable variables, and access through mutable variables cannot influence lifetimes quite as strongly as moving out of and back into somewhere, I believe.

On the other hand, it does seems rather unfortunate to me that the borrow checker isn't able to draw the same conclusions from foo_future = None here. Of course this also means that my original explanation of the situation wasn't quite right, as there isn't just "some slight overlap" between foo_future being dropped and a new borrow of foo being creates, but instead it appears rather that assignment isn't considered as relevant for borrow checking in the first place.

Or maybe the reasoning the borrow checker actually does works entirely different, yet again :innocent:

1 Like

After some playing around, I think the necessary conditions for an assignment of None not working are

  • Invariance in the returned lifetime
  • A Drop implementation (or possibility of one in the case of erased and opaque types)
2 Likes

Another fascinating observation. It’s not immediately obvious to me why variance should be relevant here at all.

Of course, the Drop implementation is relevant; without it you don’t even need the foo_future = None anymore.

So it is somehow precisely variance that’s supposed to be important here. :thinking:

I wouldn’t be surprised if there are some (significantly different) code examples where there is a significant impact in soundness from whether or not a lifetime in a type a variable (or other kind of place?) of which is being re-assigned is covariant, to motivate the borrow checker to make this distinction as a pre-condition in order to do… well, what it’s doing here. I’m not even sure I have the right terminology for the kind of effects this is having, but somehow it’s … maybe … let’s call it “disassociating the lifetime of a variable’s type from a borrow”?

I think I’m moving “learn how exactly the borrow checker works” higher up on my personal TODO :sweat_smile:

For the fun of it, I’ve just tested how polonius thinks about these cases. Looks like polonius is quite happy to accept that foo_future = None does suffice in all cases, including the invariant case.

3 Likes

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.