rustborrow-checker

Why does switching from reference to RefCell fail the borrow checker?


Rust (1.78.0) successfully compiles the following code:

struct Foo<'a>(&'a mut usize);
impl<'a> Foo<'a> {
    pub fn func(&self, _s: &'a mut String) -> &'a str {
        "hello world"
    }
}

fn test_func(arg: &Foo) {
    let mut buf = String::new();
    let _s = arg.func(&mut buf);
}

Now I change the definition of Foo to hold the reference within a RefCell:

struct Foo<'a>(std::cell::RefCell<&'a mut usize>);
impl<'a> Foo<'a> {
    pub fn func(&self, _s: &'a mut String) -> &'a str {
        "hello world"
    }
}

fn test_func(arg: &Foo) {
    let mut buf = String::new();
    let _s = arg.func(&mut buf);
}

This now fails:

error[E0597]: `buf` does not live long enough
  --> src/main.rs:12:23
   |
10 | fn test_func(arg: &Foo) {
   |              --- has type `&Foo<'1>`
11 |     let mut buf = String::new();
   |         ------- binding `buf` declared here
12 |     let _s = arg.func(&mut buf);
   |              ---------^^^^^^^^-
   |              |        |
   |              |        borrowed value does not live long enough
   |              argument requires that `buf` is borrowed for `'1`
13 | }
   | - `buf` dropped here while still borrowed

Why did this change cause the borrow checker to fail, despite not modifying the function signature? Thanks for any help!


Solution

  • The other answer was useful, correct, and pointed me in the right direction, but didn't really address the root of my confusion, so I thought I would write up my resolution.

    Previously, I falsely believed that the borrow checker checks a function's signature based on the lifetimes involved, and nothing more. But there is an additional, hidden input, which is inferred by the compiler based on the type definition.

    This is non-intuitive (to me at least). When the compiler type-checks a function call, we expect it to match-up the arguments against the function's formal parameters: what happens in the function body shouldn't matter at the call site. But here what happens in the type's "body" matters a lot at the call site.

    If the borrow checker only looked a lifetimes, this should not compile:

    struct Foo<'a>(&'a mut usize);
    impl<'a> Foo<'a> {
        pub fn func(&self, _s: &'a mut String) -> &'a str {
            "hello world"
        }
    }
    
    fn test_func(arg: &Foo) {
        let mut buf = String::new();
        let _s = arg.func(&mut buf);
    }
    

    Here arg has some (anonymous) lifetime and buf has a shorter lifetime, so it should be rejected: the _s param does not have a lifetime of 'a!

    But there is an additional "hidden" input to the borrow checker, which the Rust compiler infers based on the type definitions. This property is called "variance" and it is a subtyping relationship, related to subtyping in OO programming.

    In this case, the compiler infers (from Foo's definition) that it is valid to narrow &Foo<'long> to &Foo<'short>, which is what func does. Within func the lifetime 'a may be narrower than Foo's "real" 'a.

    Whether this narrowing is allowed is hidden, with no apparent way to make it explicit, and is fragile: it may be accidentally broken by incidental changes to the type's definition. That's how lifetime errors may be introduced without actually changing any lifetimes.