Blog post: Common Rust Lifetime Misconceptions

I wrote Common Rust Lifetime Misconceptions to help dispel a lot of the common misconceptions Rust beginners (like myself!) have about lifetimes.

Please let me know if you find anything confusing, unclear, or inaccurate! Your feedback is very important and helps me improve the article. Thanks!

23 Likes

This is a style choice, but I think it might be easier to learn from if the corrections were next to the misconceptions. That is, right now you have "misconception corollaries, exploration in detail, key takeaways", and I'm suggesting "misconception, correction, exploration".

2 Likes

This sheds new light on T: 'static and T: 'a which now both make sense, since T could be a borrowed type and thus have a lifetime.

This seems to be another misconception to me. The notation T: 'a doesn't mean that T can be &'a U for some other type U. Lifetimes have a meaning even when the type itself is not a reference. For instance, i32 is 'static. This notation constrains how long the value of that type itself is allowed to live, not how long the pointed-to value would live if it was a reference. That's what &'a T is for. I think you even describe this correctly in the next paragraph, yet somehow this previous sentence feels off in the light of the context (references).

Also, a small nitpick: what you call "code flow analysis" is usually referred to as "control flow analysis".

8 Likes

Nice post overall.

Rust borrow checker does zero code flow analysis and will always choose the shortest possible lifetime for a variable assuming all conditional blocks are taken

Ehh... I understand what you mean here, but I don't know if your model can explain code like this:

fn one_lifetime_or_two(b: bool) {
    let mut bar = String::from("bar");
    let mut bar_ref: &str = &bar;
    println!("{:?}", bar_ref);
    let foo = String::from("foo");
    if b {
        bar.clear();
        bar_ref = &foo;
    }
    println!(" -> {:?}", bar_ref);
}

or this:

fn insert_or_print_forever<K: Hash + Eq, V: Debug + Default>(map: &mut HashMap<K, V>, key: K) {
    loop {
        let value; // for bonus example, hoist this variable out of the loop
        match map.get_mut(&key) {
            Some(v) => { value = v; }
            None => {
                map.insert(key, V::default());
                break;
            }
        };
        println!("{:?}", value);
    }
}

(both of which compile). The borrow checker clearly understands the control flow in these functions to some degree, even though it doesn't know which branches are actually taken. To me, "zero code flow analysis" suggests a purely lexical borrow checker that would have to reject both of these programs.

6 Likes

Hmm, I go into every section assuming the reader holds the misconception, then I explore the misconception to dispel it, and then I provide a concise summary of the findings from the exploration as a way to conclude the section. I suppose I could move the summaries to the beginning of the sections but I feel like that might spoil some of the fun of the explorations and it'll leave the sections "dangling" without a clear firm conclusion, unless I restate the same summaries again at the end, but that would be kinda redundant.

I do appreciate the feedback though! I think your suggestion is a valid way to structure and present information, and it's something I'll keep in mind for future articles, but it's not something I'm willing to implement for this article as I think it'll require a big overhaul of most of the sections.

2 Likes

Good catch! I don't think the statement is technically wrong per se, and I thoroughly explain it over the next 2 sections in the article, but you're right, by itself it isn't the whole truth and it can still mislead people. I've updated the article to be clear that T contains borrowed types and owned types and that both borrowed types and owned types have lifetimes. Thank you for pointing this out to me.

Also thank you for the "code flow analysis" => "control flow analysis" correction! I've updated the article to use the more common term.

I messed around with your examples, and created a few of my own, and it seems the Rust borrow checker does indeed have some awareness of control flow, just not in the way I expected. I expected that if it had any control flow analysis at all it would easily pass code like this but it doesn't:

fn control_flow() {
    let string = "string".to_string();
    let borrow = &string;
    if false {
        drop(string);
    }
    dbg!(borrow); // compile error
}

After some experimentation I found that the borrow checker seems to have a basic understanding loops, the exclusivity of if-else blocks & match arms, and early exits like break and return. I'm not sure if I want to go into the technicalities of the borrow checker's control flow analysis in the article as it'll probably be overkill.

I've amended the article from saying:

  • borrow checker does no control flow analysis
  • borrow checker assumes every code path is taken

to saying:

  • borrow checker does basic control flow analysis
  • borrow checker assumes every code path can be taken

which should now make it more technically accurate.

Thank you for your feedback!

2 Likes

This is a fantastic blog post, thank you so much! As a beginner whose mind is clogged by a lot of false assumptions, your post hit my pain points precisely.

I see that in section 4 of your post, there are some examples:

fn overlap<'a>(s: &str, t: &str) -> &'a str; // no relationship between input & output lifetimes
fn get_str<'a>() -> &'a str; // pointlessly generic, since 'a must equal 'static

What is the annotation <'a> in these cases supposed to mean?

In my vague understanding, a lifetime annotation functions as a "bridge" between input an output positions, so that the borrow checker can check if the "thing" in output position cannot outlive the "thing" in input position.

However in these cases I cannot see any input position. So what is the borrow checker supposed to do?

2 Likes

I was wondering the same thing a few days ago, discussion here.

2 Likes

When the lifetime on the return value is not tied to any argument, the caller can choose whatever they want, including 'static.

2 Likes

Thanks for the replies! The other mentioned thread is helpful too. However I don't quite understand what does "choose" or "pick" in the above three quotes mean. For example, what does the caller have to do to "choose" a lifetime for the return value of Box::leak?

You can choose a lifetime like you can choose a generic parameter explicitly:

pub fn foo<'a>(a: &'a str) -> &'a u32 {
    let boxed = Box::new(10);
    
    println!("{}", a);
    
    Box::leak::<'a>(boxed)
}

If you don't specify one, Rust will pick the smallest lifetime that satisfies the requirements. In this case, there is one requirement, namely that it must contain 'a, so without specifying it, it would choose 'a.

You could also choose 'static, in which case the return value is automatically coerced to the shorter lifetime after it was returned:

pub fn foo<'a>(a: &'a str) -> &'a u32 {
    let boxed = Box::new(10);
    
    println!("{}", a);
    
    // The compiler inserts a conversion from `&'static u32` to `&'a u32` here
    Box::leak::<'static>(boxed)
}

However there are some cases where choosing 'static and then shortening doesn't work.

Note that with the foo method above, the caller also chooses that 'a. It's just that in the case of foo, the caller must provide a &str with the lifetime they chose, so if they choose 'a = 'static, they must provide a string slice with the static lifetime. All generics are always chosen by the caller, it's just that when the lifetime doesn't appear in the arguments (or where bounds), there is nothing to restrict their choice.

All of this applies to generic types too, by the way.

3 Likes

Fantastic explanation, thank you! I'll digest as much as I can, if I have further questions I'll open a new thread to avoid derailing this one.

I wish cargo expand is able to expand the lifetimes too.

2 Likes

In section 10 (closure lifetime elision):

There's no good reason for this discrepancy. Closures were first implemented with different type inference semantics than functions and now we're stuck with it forever because to unify them at this point would be a breaking change.

Could we change the elision rules in a new edition?

1 Like