Branching borrow checker issue

Can someone explain why this fails?

struct Test {
    inner: Vec<u8>,
}

impl Test {
    fn fails_but_why(&mut self) -> &mut u8 {
        // a fairly expensive/recursive call here
        if let Some(last) = self.inner.last_mut() {
            return last;
        }
        // only the plain reference is required at this point
        let as_ref = self.inner.as_slice();
        todo!("do something with `&[u8]`")
    }
    // is NOT something I'm willing to settle on
    fn works(&mut self) -> &mut u8 {
        let is_some = self.inner.last_mut().is_some();
        match is_some {
            true => self.inner.last_mut().unwrap(),
            false => {
                let as_ref = self.inner.as_slice();
                todo!("do something with `&[u8]`")
            }
        }
    }

}

I'm assuming it is yet another edge case, not handled properly by the current implementation of the compiler? Is there any well-known workaround, that would not require turning the first mutable borrow into a boolean true/false check, simply to "detach" the lifetime from the first call, before branching into either the former if let or the following as_ref?

Yeah it looks like the limitation of the current borrow checker. I guess the checker thinks the &mut lifetime is extended until the entire method execution end because a value that depends on said &mut is returned to the outside. Then the checker see &, so it gives compile time errors can't use immutable reference because mutable reference is still active. It looks like only happen with &mut

While logical analysis, the immutable reference ends directly because there is return keyword, the execution is returned to the caller, so the immutable reference does not get a chance to run

The workaround without additional runtime branching will need to revert the order, & at the top, &mut at the bottom. And making sure the & ends before entering &mut

This is what I can think of


struct Test {
    inner: Vec<u8>,
}

impl Test {
    fn fails_but_why(&mut self) -> &mut u8 {
        // put the immutable reference branch at the top
        if let None = self.inner.last_mut() {
             let as_ref = self.inner.as_slice();
             todo!("do something with `&[u8]`")
        }

        // put the mutable reference branch at the bottom. the immutable reference lifetime ends in the end of the if let None
        // safe to call unwrap because it is guaranted this is the Some(val) branch
        self.inner.last_mut().unwrap()
    }
    // is NOT something I'm willing to settle on
    fn works(&mut self) -> &mut u8 {
        let is_some = self.inner.last_mut().is_some();
        match is_some {
            true => self.inner.last_mut().unwrap(),
            false => {
                let as_ref = self.inner.as_slice();
                todo!("do something with `&[u8]`")
            }
        }
    }

}

fn main() {
    
}

This is less “yet another” edge case and more the one big problem case: conditional returns of exclusive borrows. When the borrow checker sees an exclusive (mutable) borrow which may be returned, it treats it as if its lifetime extends to the end of the function, even if it was dropped instead of returned. In this particular case, the problematic borrow is the &mut self.inner borrow implicitly created by the call to last_mut().

The solution, in general, is to avoid creating the exclusive borrow until such time as you are certain you are going to return it. In this case, that can be done with pattern matching (which does not borrow until the match succeeds); the following code compiles:

impl Test {
    fn works(&mut self) -> &mut u8 {
        match self.inner.as_mut_slice() {
            [.., last] => last,
            other => todo!("do something with `&[u8]`"),
        }
    }
}

However, since the slice in the second case is always going to be empty, your real situation is presumably more complex in some way, so further changes in strategy may be needed; if you show more details I can try to find a rewrite that works.

The fallback, if you can't find a way to do it within the language, is to use polonius_the_crab, a library that uses unsafe to enable this particular code structure.

Is there a particular reason why this is difficult to implement? Looking at the code, it seems like this:

if let ... = self.inner.last_mut() {    
    ...
}

It would be able to see that the scope of whatever binds the last_mut() ends at the }, so the borrow ends there. (?)

It requires some control-flow analysis, to say that borrows have different lengths depending on which branch is taken. It’s still feasible enough to implement that it has in fact been implemented in the Polonius borrow checker, it’s just not on stable Rust yet.

Part of the challenge is implementing such an analysis efficiently. Something[1] that could handle this particular case was described in the NLL RFC (and AFAIK was even implemented), but some pieces needed for this use case ended up being scrapped as they hurt compilation time too much.

Polonius takes a different approach which required a larger rewrite.[2] If you want to see the PRs that have gone into the current implementation, lots of them are linked from here.[3] Skimming comments, I think there will still be a performance hit,[4] just not as severe, and there's at least one unsoundness being actively worked on.


  1. location sensitive subtyping ↩︎

  2. It can also handle cases location-sensitive NLL could not, at least theoretically. The current alpha doesn't solve every known case though. ↩︎

  3. There's also an older POC implementation that used some sort of Prolog-esque sublanguage (datalog) instead of analysis directly on the internal representations used elsewhere throughout the compiler. ↩︎

  4. which makes sense, more work is being done ↩︎

it would do that if you were using the borrow only inside the block but because it gets returned it has to be extended