Search code examples
rustreferencestackborrow-checker

Why doesn't Rust understand that a reference is no longer borrowed?


In Rust when I borrow a value, the compiler takes notice, but when I replace it the compiler does not notice and issues an E0597 error.

Given a mutable variable that contains a reference x. When I replace its content, with the reference to a local variable, and before the local goes out of scope I replace it back to the original.

Here is a code that shows this:

struct X {payload : i32}

fn main() {
    let pl = X{payload : 44};
    {
        let mut x = &pl;
        {
            let inner = X{payload : 30};
            let tmp = std::mem::replace(&mut x, &inner);
            println! ("data ={:?}", x.payload);
            let _f = std::mem::replace(&mut x, &tmp);
        }
        println! ("data ={:?}", x.payload);
    }
}

The error is:

error[E0597]: `inner` does not live long enough
  --> src/main.rs:9:49
   |
9  |             let tmp = std::mem::replace(&mut x, &inner);
   |                                                 ^^^^^^ borrowed value does not live long enough
...
12 |         }
   |         - `inner` dropped here while still borrowed
13 |         println! ("data ={:?}", x.payload);
   |                                 --------- borrow later used here

For more information about this error, try `rustc --explain E0597`.

The compiler notices when I assign a reference of inner to x, but overlooks the fact that while inner is still alive I replace this reference with the original one to pl again.

The expected output should be:

data =30
data =44

What am I doing wrong?


Solution

  • It seems you are missing that the lifetimes of references is part of their type, and not some dynamically tracked metadata. So in the original code, the lifetime associated with x has to stop at the end of the nested block because the lifetime associated with it had to "merge" with the lifetime of inner, which ends there. It does not matter that you put it back since that does not change the type.

    Now to address the nuances discovered in the self-answer:

    However a small tweak will make it compile without errors or warnings.

    // Compiles without errors/warnings.
    struct X {payload : i32}
    
    fn main() {
        let pl = X{payload : 44};
        {
            let mut x = &pl;
            println! ("data ={:?}", x.payload);
            {
                let inner = X{payload : 30};
                x = &inner;
                println! ("data ={:?}", x.payload);
                x = &pl;
            }
            println! ("data ={:?}", x.payload);
        }
    }
    

    This makes me believe that there is a compiler bug. Because now the compiler catches that the lifetime of inner decouples from the lifetime of x.

    ALAS. When you put the inner block into a separated function the problem comes back. So it was just a case that the Rust compiler has some optimization code-path that was catching the corner case.

    The critical knowledge to reason about why some examples work and some don't is largely due to the principle that the borrow checker does not reason outside the current function body.

    This is seen both ways:

    • if you use std::mem::replace:

      The borrow checker does not know what std::mem::replace does. It only sees that it is a function that takes in a &mut T, T, and returns T. Thus you can see how the it will reject this code because it cannot reason that x has its original value. The borrow checker will not look at how replace is implemented to reason about the local code.

    • if you separate it into a separate function:

      fn inner_func(x: &mut &X) {
          let inner = X { payload: 30 };
          let tmp: &X = *x;
          *x = &inner;
          println!("data ={:?}", x.payload);
          *x = &tmp;
      }
      

      Then the borrow checker sees that it is clearly wrong: x's lifetimes by-construction live outside of the scope of inner_func so any local variables will have smaller lifetimes and will be incompatible. In its current form, this is definitely unsound since println! could panic and the caller could catch that and end up with a dangling reference. Again, the borrow checker does not look at what main is doing to reason about the local code.

      The rest of it fails similarly to the other example using a tmp variable. The borrow checker does not reason that this will restore the original lifetime. Call this a missed opportunity if you want, but there's no reason for the compiler to handle this sequence of changes in a special way. You can simple reborrow if you want to shorten the lifetimes to use within a nested scope.

    So in summary, it is not a compiler bug, it is simply how the borrow checker is implemented and it works by design. In fact, the working example is the ugly duckling and the borrow checker only allows it since it knows the entire lifetime and interactions of x and thus can hand-wave away what I wrote at the beginning of my answer.

    What am I doing wrong?

    If you have this kind of code in a real use-case, there's no reason to reuse x, just make a new reference in the nested scope:

    struct X { payload: i32 }
    
    fn main() {
        let pl = X { payload: 44 };
        let x = &pl;
        {
            let mut x = x; // reborrow so that the new x can have a smaller scope
            let inner = X { payload: 30 };
            let tmp = std::mem::replace(&mut x, &inner);
            println!("data ={:?}", x.payload);
            let _f = std::mem::replace(&mut x, &tmp);
        }
        println!("data ={:?}", x.payload); // uses original x
    }