Updating a struct holding a reference field, and the effect on the struct's lifetime

Say I have a struct like so:

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

I interpreted that this meant that this struct is initialized with a lifetime reference to some i32 x, and the struct cannot live longer than the reference. Nothing stops me from updating the reference at other points in the code to some other reference possibly of a shorter lifetime. Its just that when I do update the reference to some shorter lifetime, the lifetime of the struct itself is tied to this shorter lifetime, and the struct cannot outlive the new reference, so something like this should be possible:

fn main() {
    let first_num = 1;
    let mut f = Foo { x: &first_num };
    {
        let second_num = 2;
        f.x = &second_num;
        println!("{}", f.x);
    }
}

I thought that the above code works because f never exceeds the lifetime of second_num.

But now say I do something like this:

fn main() {
    let first_num = 1;
    let mut f = Foo { x: &first_num };
    {
        let second_num = 2;
        f.x = &second_num;
        println!("{}", f.x);
        f.x = &first_num;
    }
    println!("{}", f.x);
}

I am reassigning the reference to first_num to f.x. So now the struct Foo should have its lifetime tied to the lifetime of first_num. But yet the compiler says that second_num does not live long enough .

I guess that this means my mental model of references held in structs is wrong, but I'm looking for an explanation that can help me understand how to think about lifetimes of structs with respect to the references inside them, given that a struct can always be updated to hold a new reference with a different lifetime.

First of all, you need to keep in mind that the type of a variable can't change, and this includes lifetimes. The lifetime is a generic parameter, and it will be inferred and concretized to a single concrete lifetime when you instantiate the struct. The compiler will therefore have to assign the lifetime that satisfies all references, ie. their intersection, ie. the shortest of the lifetimes.

In the first snippet, you never actually access the reference field while it points to first_num. Since the compiler doesn't equate lifetimes with scopes (NLL = non-lexical lifetimes), it can arbitrarily decide to shorten the borrow of first_num even though it's still in scope, and then infer the lifetime to be the lifetime of the borrow of second_num (which is shorter).

In the second snippet, this lifetime shrinking is impossible, because you are still using the borrow of first_num after that of second_num. Hence the compiler would have to assign the longer lifetime to the lifetime parameter of the struct. This in turn isn't satisfied by the shorter borrow, hence the error.

4 Likes

See also this other recent topic.

1 Like

Thanks @H2CO3. I understand now that lifetimes are concrete like any other generic parameter and cannot be changed once decided at initialization.

However I am still a little unclear about how the lifetime of a struct is determined. When you say:

The compiler will therefore have to assign the lifetime that satisfies all references, ie. their intersection, ie. the shortest of the lifetimes.

Does this mean that the compiler checks every possible reference that is (or in other lines will be) possibly assigned to this struct's field, and then assigns a lifetime to the struct that satisfies their intersection? Or does it just assign the lifetime based on the reference being provided to initialize the struct on that line.

In either case how come code like this compiles just fine:

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

fn main() {
    let first_num = 1;
    let mut f = Foo { x: &first_num };
    println!("{}", f.x);
    {
        let second_num = 2;
        f.x = &second_num;
        println!("{}", f.x);
    }
}

Here I am accessing f.x outside the scope of second_num to disallow the compiler to shrink the lifetime of first_num. But then I am assigning a shorter lifetime of second_num to the struct Foo.

For every piece of code, e.g.

    let mut f = Foo { x: &first_num };
// let mut f: Foo<'foo> = Foo { x: &'x first_num }

the compiler does things like

  • Notes the existence of new lifetimes ('foo, 'x)
  • Notes the existence of new borrows of places (x is shared-borrowed for 'x)
  • Notes constraints between lifetimes that must hold ('x: 'foo)

and

println!("{}", f.x);
  • Notes where lifetimes are "used" ('foo must be alive here)
  • And their transitive effects ('x: 'foo so 'x must be alive here too)

And finally looks at the usage of all borrowed places and sees if they conflict with where the calculated lifetimes must be alive. Arguably no concrete lifetime is calculated per se, instead the compiler proves that the diagnosed borrows and constraints can be satisfied without conflicting with the uses.

(This is a sketch; the algorithm is pretty involved.)


The analysis is control-flow aware, so it doesn't matter that there was a usage of 'foo before second_num existed and the borrow &second_num was created. There just can't be any conflicting uses of second_num after the creation of the borrow.

Going out of scope (at the bottom of the block) is considered a use of second_num. That's the main way lexical scopes still interact with lifetimes. It doesn't matter that the lifetime in question existed and was used before the inner scope started.

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.