rustborrow-checker

Difficulty understanding the logic of borrowing rules


I'm reading "the book", and wanted to test my understanding of the borrowing rules in chapter slicing.

I was impressed with (my assumption of) how the borrow checker associated what I passed in, with what the function returned. So I tried some examples to test my understanding, and I'm struggling to figure out the rules of the borrow checker.

  1. Why does test1 fail to compile, where test5 works?
  2. Where is my understanding broken? (see thought process below)
  3. Where does "the book" cover what I'm getting lost on?

This code is always the same:

fn tmp() {
    let mut s = String::from("hello world");

    let thing = testX(&s);

    s.clear(); // error!

    let tmp = thing.len();
    println!("the first word is: {tmp}");
}

Test 1:

fn test1(s: &String) -> &str {
    "Slice"
}

Here I'm passing in an immutable reference of a mutable string, but don't use it in the function. I return a different slice (in my mind, a different immutable string). I believe that once test1 returns it no longer holds its borrow on &s. I expected this code to compile, but it does not, I still somehow hold an immutable borrow on s.

So I wondered if the borrow checker correlates the type returned with the type which was passed in.

Test 2:

fn test2(s: &String) -> Vec<&str> {
    let mut vec = Vec::new();
    vec.push(s.as_str());
    vec
}

I tried to be creative with more indirection. I have no idea what as_str() does ownership-wise, but I'm interested to learn.. Here I am passing back a slice of what was passed in, so I'm not surprised that it doesn't compile, but I already suspect my understanding of the rules is wrong.

Test 3

fn test3(s: &String) -> Vec<&str> {
    let mut vec = Vec::new();
    vec.push("Slice");
    vec
}

I follow similar rationale to test1, also doesn't compile. This tells me it's not about returning a reference type of what type was passed in, unless it's extending reasoning to the type of the owning vector..?

Test 4

fn test4(s: &String) -> Vec<&usize> {
    let mut vec = Vec::new();
    vec.push(&1);
    vec
}

Now I'm getting sneaky, I'm creating code I think is comparable to test3, but returns an unrelated type. Still the compiler doesn't allow it. So it would appear that for some reason the function is holding onto the borrow of &String after it returns?!

Test 5

fn test5(s: &String) -> Vec<String> {
    let mut vec = Vec::new();
    vec.push(String::from("something new"));
    vec
}

I try this, fully expecting it to also not compile, but it works!

I see it as logically the same as test1 with regard to the borrowing of s. In both cases I'm doing nothing with s, yet test1 holds onto the borrow, while test5 does not.

Help!


Solution

  • I'll take a crack at test1 first — hopefully it will give you some insights into the other cases, too.

    When you do:

    fn test1(s: &String) -> &str
    

    ... there's an implicit lifetime specifier being set, and it's the same for both borrows. It's as if you'd written:

    fn test1<'a>(s: &'a String) -> &'a str
    

    That means that as far as the caller of test1 knows, the lifetimes are the same, and &str could hold onto some data that the incoming &String had. In fact, this is would also compile:

    fn test1(s: & String) -> &str {
        s
    }
    

    From the caller's perspective, it doesn't know what's going on in the body, and so the borrow checker has to "assume the worst", as it were — that is, that the body is something like that last snippet. Now you can see why s.clear() is an error: you're clearing out underlying memory that thing depends on!

    You can fix this by providing explicit lifetime specifiers. Either of these will work:

    // return value has static lifetime
    fn test1(s: & String) -> &'static str {
        "Slice"
    }
    
    // input arg and return type have separate, and unrelated lifetimes
    fn test1<'a, 'b>(s: &'a String) -> &'b str {
        "Slice"
    }
    

    Most of your other testX's are basically variants on the theme: you're telling the compiler that the return value is in some ways tied to the lifetime of the input.

    test5 is the exception: the return value doesn't refer to the borrow lifetime of the input at all, but instead is its own, fully-owned entity. String::from copied the data to a new String (rather than referencing the data in the original string), and the fact that there's no borrow formalizes that to the type system. With that, the type system says "ah, nobody else is relying on s, so I'm free to allow modifications of it."