What's the literal meaning of liftimes in a Rust function / struct definition?

Viewed 221

I've been trying to find a good explanation of Rust lifetimes. I get the idea that they are our way of telling the compiler which variables have to outlive which, so it can verify that references are non-dangling without having to check every code path. What I don't get is what we are actually saying when we use them. Take this example:

fn foo<'a>(bar: &'a i32, baz: &'a i32) -> &'a i32 {
    bar
}

fn main() {
    let m = 5;
    let x = &m;
    {
        let n = 6;
        let y = &n;
        {
            let z = foo(x, y);
            dbg!(z);
        }
        dbg!(y);
    }
    dbg!(x);
}

This compiles and runs fine. Here x, y, and z all live for different lengths of time. The underlying data, m and n, survive for different times as well. So clearly when we declared the arguments and the return value of foo to all have lifetime 'a, we weren't saying they all live for the exact same stretch of the program. (I'm trying not to say "lifetime", sorry for the awkward phrasing.)

The other interpretation that I've heard is we're saying there exists some lifetime 'a at the intersection of the lifetimes of bar, baz, and the return value. However, this would be a trivial statement, as clearly they are all in scope at the moment we call foo.

What it seems to me we are saying, is that there exists some lifetime 'a such that bar and baz outlive 'a, and the return value is outlived by 'a. This would mean that lifetime annotations have a different meaning when used on arguments versus when used on return values: it would mean that annotations on arguments are lower bounds on the lifetime and annotations on return values are upper bounds. This interpretation makes the most sense, but I have a feeling that this isn't correct either, because I think &'a T is (sort of like) a type, and it wouldn't make sense for a type to have a different meaning on arguments versus on return values. If the same notation has a different meaning in different places, then wouldn't it add a layer of impurity into the language where the same syntax can have different meanings and we just have to memorize which places it has each meaning?

So can anyone explain what lifetimes actually mean?

As a part two to this question, can we also explain what lifetimes mean in a struct definition? I'm asking for this as well because I suspect the answer is different than for function definitions. Here's an example:

struct Foo<'a> {
    bar: &'a i32
};

fn main() {
    let foo = Foo { bar: &5 };
}

Again, here it seems like we're saying that foo.bar outlives 'a and foo is outlived by 'a. So again we have this different meaning for lifetimes on the struct template parameter versus on the struct member.

2 Answers

The other interpretation that I've heard is we're saying there exists some lifetime 'a at the intersection of the lifetimes of bar, baz, and the return value.

This seems reasonable.

You can think of that at the point of the foo(x, y) call the compiler tries to satisfy the types of the parameters with the types of the provided arguments, and "casts" implicitly if they are compatible.

You can "cast" a longer lifetime to a shorter one for a simple reference, so if lifetime of z is 'z, the compiler is able to unify the call to:

let z: &'z i32 = foo<'z>(x as &'z i32, y as &'z i32);

However, this would be a trivial statement, as clearly they are all in scope at the moment we call foo.

I wouldn't call it trivial, but indeed it is easier if you have all your code in one function to perform this scope analysis, and it is indeed performed in C/C++ and some other languages. Performing it for the whole program tree would take too long, and explicit annotations help with that. Imagine a more complex case: foo internally calls bar, bar calls baz, some intermediate structs are created and moved with those refs, those include nested structs, and what if that it is not just a linear piece of code, but async, and wraps things in Futures, and awaits them along the way...

annotations on return values are upper bounds

It doesn't have to. In your case return <'a> is the same as the lower bound, but it could be tied to &'s self or be 'static, or something else.

If your function foo accepts covariant types like x: &'a i32 and y: &'b i32 where 'a: 'b - yes, they unify to the one with a lower bound, but if your function accepts contravariant types like x: Fn(&'a i32) -> () and y: Fn(&'b i32) -> (), then it unifies to the one with an upper bound, because now foo() is responsible for calling, and if it is able to provide a longer-lived argument for both - it works for both.

So again we have this different meaning for lifetimes on the struct template parameter versus on the struct member.

The declaration of Foo<'a> just gives a name to the lifetime of bar field. When you create foo, the compiler performs inference based on the lifetimes (or scopes) of the arguments, and deduces that in that particular case for example 'a = 'static, because 5 is a constant, kind of like if you were able to manually write:

let foo = Foo<'static> { &5 as &'static i32 }

On the other hand if you create it with "z" from the previous example, it could be:

let z = ...
let foo = Foo<'z> { z as &'z i32 }

Note: there's no way to specify local code block lifetimes to hint/force compiler to use them, we can only specify it on the function boundaries or in types.

This link that @user4815162342 shared seems to explain what's happening pretty well. I'm going to try and paraphrase my takeaways:

First off, the lifetime is part of the type. Longer lifetimes are subtypes of shorter lifetimes (that they outlive on both ends). This makes sense because if you expect your value to live for some short lifetime, then it's certainly fine if it also lives for some longer time. Just like how if you're expecting something with the parent type Animal then it's certainly fine if it's of the more specific subtype Cat. (It's a bit more complicated than this, read that link to learn about type covariance.)

Second, you can always put subtypes into variables that expect supertypes. Say we have the type hierarchy Cat: Animal: Life. A function that takes an Animal could also take a cat, but couldn't take a Life because it may use more specific properties of Animals. (Of course in Rust we couldn't have instances of Animal and Life, but let's ignore that for now.) Conversely, a function that returns an Animal could have its return value used as a Life, but it couldn't be used as a Cat because it might not be a Cat.

Combine these two points and the behavior of a function fn foo(&'a T) -> &'a T makes sense: the value passed to the argument must be a subtype of &'a T, so it must live at least as long as 'a. The value returned can only be used as a supertype of &'a T, so it can only be assumed to live as long as 'a. Together we get that the argument outlives the return value.

Related