3

I have been having a hard time understanding lifetimes and I would appreciate some help understanding some of the subtleties which are usually missing from the resources and other question/answers on here. Even the whole section on the Book is misleading as its main example used as the rationale behind lifetimes is more or less false (i.e. the compiler can very easily infer the lifetimes on the mentioned function).


Having this function (kinda similar to the book) as an example:

fn foo<'a>(x: &'a str, y: &'a str) -> &'a str {
    x
}

My understanding is that the explicit lifetimes asserts that the returning reference should not live longer than the shortest of the lifetimes of x and y. Or in other words, both x and y should outlive the returning reference. (Although I'm completely unsure of what exactly the compiler does, does it check the lifetimes of the arguments and then compares the minimum with with life time of the returning reference?)

But then what would the lifetime mean if we have no return values? Does it imply a special meaning (e.g. compared to using two different lifetimes?)

fn foo<'a>(x: &'a str, y: &'a str) {
    
}

And then we have structs such as:

struct Foo<'a, 'b> {
    x: &'a i32,
    y: &'b i32,
}

And

struct Foo<'a> {
    x: &'a i32,
    y: &'a i32,
}

It seems using the same lifetime for the fields adds some constraints, but what exactly is that constraint which causes some examples not to work?


This one might require a question of its own, but there are a lot of mentions of lifetimes and scopes being different but without elaborating much, are there any resources delving deeper on that, especially also considering non-lexical lifetimes?

zareami10
  • 111
  • 7

1 Answers1

6

To understand lifetimes you have to note that they are actually part of the type, not of the value. This is why they are specified as generic parameters.

That is, when you write:

fn test(a: &i32) {
    let i: i32 = 0;
    let b: &i32 = &i;
    let c: &'static i32 = &0;
}

then variables a, b and c are actually of different types: one type is &'__unnamed_1 i32, the other is &_unnamed_2 i32 and the other is &'static i32.

The funny thing is that lifetimes create a type hierarchy, so that when a type lives longer than another type, but except that they are the same, then the long-lived one is a sub-type of the short-lived one.

In particular, in a case of extreme multiple inheritance, the &'static i32 type is a subtype of any other &'_ i32.

You can check that Rust sub-types are real with this example:

fn test(mut a: &i32) {
    let i: i32 = 0;
    let mut b: &i32 = &i;
    let mut c: &'static i32 = &0;
    //c is a subtype of a and b
    //a is a subtype of b
    a = c; // ok
    c = a; // error
    b = a; // ok
    a = b; // error
}

It is worth noting that lifetimes are a borrow checker issue. Once it is satisfied and the code is proven to be safe, lifetimes are erased and the code generation is done blindly, assuming that all accesses to memory values are valid. This is why even though life times are generic parameters, foo<'a>() is only instantiated once.

Going back to your examples:

fn foo<'a>(x: &'a str, y: &'a str) -> &'a str {
    x
}

You can call this function with values of different lifetimes, because the compiler will deduce 'a as the shorter of both, so that &'a str will be a super-type of the other one:

    let s = String::from("hello");
    let r = foo(&s, "world");

This is equivalent to (invented syntax for lifetime annotation):

    let s: &'s str = String::from("hello");
    let r: &'s str = foo::<'s>(&s, "world" as &'s str);

About the structures with multiple lifetimes, it generally doesn't matter, and I usually declare all lifetimes the same, particularly if the type is private to my crate.

But for public generic types, it may be useful to declare several lifetimes, particularly because the user may want to make some of them 'static.

For example:

struct Foo<'a, 'b> {
    x: &'a i32,
    y: &'b str,
}

struct Bar<'a> {
    x: &'a i32,
    y: &'a str,
}

The user of Foo may use it as:

let x = 42;
let foo = Foo { x: &x, y: "hello" };

But the user of Bar must allocate a String, or do some stack-allocated str wizardry:

let x = 42;
let y = String::from("hello");
let bar = Bar { x: &x, y: &y };

Note that Foo can be used just like Bar but not the other way around.

Also the impl Foo may provide additional functions that are not possible for impl Bar:

impl<'a> Foo<'a, 'static> {
    fn get_static_str(&self) -> &'static str {
        self.y
    }
}
rodrigo
  • 94,151
  • 12
  • 143
  • 190
  • Thanks. So isn't it that only subtypes can be coerced into base/supertypes? Then how does the return value satisfy `'a` in `foo<'a>(x: &'a str, y: &'a str) -> &'a str` when it's actually a superype? The return value doesn't even need to be a superype as `&'static' (a subtype) would also be a valid return type. Besides these we also seem to have lower bounds involved as we can't return a reference to a local function variable.So although these all make sense in order to avoid dangling references, I'm still unsure what it is that the compiler is checking for when it comes to lifetime parameters. – zareami10 Aug 13 '21 at 11:18
  • 1
    @zareami10: When you do `foo<'a>(x: &'a str, y: &'a str) -> &'a str` all `x`, `y` and the return value are for the same type, so you can trivially do `return x` or `return y`. The type conversion is done at call site, as I described in my paragraph with the invented syntax. – rodrigo Aug 13 '21 at 11:32
  • 1
    @zareami10: You can return a `&'static str` because that type is a subtype of `&'a str`. But you cannot return a pointer to a local variable `&_local str` simply because that is not a subtype of `&'a str`. – rodrigo Aug 13 '21 at 11:33
  • Thanks, it makes more sense now. So from my understanding the lifetime parameters are actually referring to the lifetime of the values rather than the references, and that's why `'a` can represent the same lifetime for both the input and output (since well the lifetimes of the references themselves could be different). Is that correct? – zareami10 Aug 13 '21 at 11:50
  • 1
    @zareami10: Lifetime is the time a value is alive, but it is also a property of a reference type, that limits what it can refer to. The `foo()` function does not know the exact lifetimes of the values pointed by `x` and `y`, but it knows that they live for at least `'a`. That is because the type of `x` and `y` **is** `&'a i32`. Lifetime rules ensure that any instantiation of `foo` and any usage of such instantiations are safe. – rodrigo Aug 13 '21 at 12:01
  • 1
    Yes, `foo()` can do `return "a static value"` but that `&'static str` is converted into a `&'a str` in the caller frame, so that it cannot use it for longer than `'a` anyway. – rodrigo Aug 13 '21 at 12:05
  • So as you mentioned it seems that lifetime coercion happens both in a function's arguments and in its return value. But interestingly it seems in a function body you can assign a `&'static` to a non-static variable *without* actually coercing `'static` to a shorter lifetime, for example (if I haven't made any mistakes): `fn main() {let mut b: &i32; {let i: i32 = 0;b = &i; let mut c: &'static i32 = &0; b = c;} println!("{}", b);}`. So I was wondering whether there is a reason behind this difference? – zareami10 Aug 14 '21 at 15:15
  • @zareami10: Well, in my answer I did a bit of a lie/oversimplification: in non-lexical lifetimes (and in a (hopefully near) future the Polonius borrow checker), some code that _should_ be rejected by type analysis alone is actually accepted, because additional information about _variable_ lifetimes convince the compiler that the code is actually safe. – rodrigo Aug 14 '21 at 16:06
  • @zareami10: In your comment code, if I'm not mistaken, a literal type analysis would reject your code simply because variable `b` lives longer than its type. Indeed if you comment your `b = c;` it will fail. (We can continue on the chat if you wish.) – rodrigo Aug 14 '21 at 16:10