Can the lifetime of a struct instance change?

For a struct defined to contain a borrowed reference, does declaring an instance of it as mutable mean that the lifetime of the struct instance can change?
I read in an answer on Stackoverflow that lifetimes are burned into a variable once it is initialized and that lifetime can't change even if the variable is mutable. For example:

#[derive(Debug)]
struct Person<'a> {
    email: &'a String,
}

fn main() {
    let email_1 = "first_email@email.com".to_string();
    let mut person;
    {
        let email_2 = "second_email@email.com".to_string();
        person = Person {
            email: &email_2,
        };
        println!("{:?}", person);
        person.email = &email_1;
    }
    println!("{:?}", person);
}

And, as expected:

error[E0597]: `email_2` does not live long enough
  --> src\main.rs:33:20
   |
33 |             email: &email_2,
   |                    ^^^^^^^^ borrowed value does not live long enough
...
37 |     }
   |     - `email_2` dropped here while still borrowed
38 |     println!("{:?}", person);
   |                      ------ borrow later used here

So, it seems like the lifetime information does get burned into the variable holding the struct instance. Although we assigned a different, longer-living reference to the Person instance's field, its lifetime was still inferred from and tied to the value that was initially assigned to it. Fair enough.
But wouldn't the following code then lead to a dangling reference in the Person instance's field?

#[derive(Debug)]
struct Person<'a> {
    email: &'a String,
}

fn main() {
    let email_1 = "first_email@email.com".to_string();
    let mut person = Person {
        email: &email_1,
    };
    {
        let email_2 = "second_email@email.com".to_string();
        person.email = &email_2;
    }
    println!("{:?}", person);
}

The lifetime of the Person instance should now be tied to the scope of email_1 and consequently it should be available for use in the last line, leading to the creation of a dangling reference in its field. Of course the borrow checker doesn't let this happen and raises this error:

error[E0597]: `email_2` does not live long enough
  --> src\main.rs:34:24
   |
34 |         person.email = &email_2;
   |                        ^^^^^^^^ borrowed value does not live long enough
35 |     }
   |     - `email_2` dropped here while still borrowed
36 |     println!("{:?}", person);
   |                      ------ borrow later used here

But what principle is at play here?
Can the lifetime of a mutable struct instance change according to what reference is assigned to it? If so, why does the first piece of code fail?

No, the type of a place/variable can't change.

I’m not certain what the exact rules on this are.

It’s often the case that the lifetimes in a variable cannot change, as your first example demonstrates. The second example isn’t all that interesting at is must necessarily not compile, to prevent use-after-free. You ask “what principle is at play?” and that’s a relatively broad question. Your question seems to imply that something – perhaps about the error message – in that second example has you guessing that the lifetime of the struct has changed, whereas I don’t see why one should derive that conclusion. All examples you showed exhibit, as far as I can tell, consistently behavior of “the lifetime of the variable doesn’t change”.

Notably there are exceptions though, and I’m not actually sure what the exact scope of them is, hence my first sentence. E.g. a variable holding just a reference can contain references of different lifetime in code such as:

fn main() {
    let mut reference: &i32;
    {
        let box1 = Box::new(1);
        reference = &*box1;
        println!("{reference}");
    }
    {
        let box2 = Box::new(2);
        reference = &*box2;
        println!("{reference}");
    }
    {
        let box3 = Box::new(3);
        reference = &*box3;
        println!("{reference}");
    }
}

Even in your first example, if you re-write the update operation to instead re-assign to the whole person, it compiles (and runs) successfully.

- person.email = &email_1;
+ person = Person {
+     email: &email_1,
+ };

Edit: Re-reading the question

I think the principle you are missing is coercions! An assignment such as person.email = &email_1; in the first example does not simply blindly do the assignment whilst keeping the field’s lifetime the same as the initially assigned one. Instead, when the lifetime in the Person<'_> type is different from the lifetime of the reference being assigned to it’s field, there needs to be a coercion happening, typically via covariance, where a longer-lived &str reference can be converted into a shorter-lived one, but not vice-versa. This gives rise to a lifetime constraint (i.e. something of the form “lifetime foo must outlive lifetime bar”).

In fact, these coercions happen so universally that the notion that “the struct’s lifetime is inferred from the first value assigned to its field” is wrong. Instead, e.g. with the email: &email_2 initialization, the inferred lifetime for the Person struct would always just be “some unknown lifetime that’s at most as long as the lifetime of the &email_2 borrow”. The assignment person.email = &email_1; gives rise to another constraint “this same unknown lifetime is also at most as long as the lifetime of the &email_1 borrow”. And finally the use case in the println gives rise to a constraint like “this same lifetime is at lest as long as up to this println statement”. Once any of these constraints contradict, you get a borrow checking error.

I’m deliberately calling it an “unknown lifetime” throughout the last few sentences, as that’s really what it is. In many cases, the compiler never actually figures out the precise lifetimes of things, even in programs that compile successfully. It doesn’t have to; lifetimes may stay under-specified! The borrow-checker’s approach is usually just one of asking “is there a contradiction in the requirements?” and if there’s no contradiction, that means that (arbitrarily) choosing an appropriate lifetime would be possible in principle, whereas the exact choice doesn’t actually matter as lifetimes have no run-time effects.

2 Likes

Be wary of assigning too much meaning to scopes. Lifetimes can start in one scope and end in the middle of some nested scope [1].

If you remove the println of your second example, it compiles. That demonstrates that the lifetime is not forced to be as long as the scope of email_1:

fn main() {
    let email_1 = "first_email@email.com".to_string();
    let mut person = Person {
        // Lifetime must be valid here
        email: &email_1,   // ---- start ----+
    };  //                                   |
    {   //                                   |
        let email_2 = "...".to_string(); //  |
        // Lifetime must be valid here       |
        person.email = &email_2; //          |
                           // ----- end -----+
        // Lifetime not used here or after
    }
    // println!("{:?}", person);
}

But with the println:

fn main() {
    let email_1 = "first_email@email.com".to_string();
    let mut person = Person {
        // Lifetime must be valid here
        email: &email_1,   // ---- start ----+
    };  //                                   |
    {   //                                   |
        let email_2 = "...".to_string(); //  |
        // Lifetime must be valid here       |
        person.email = &email_2; //          |
    } // `email_2` drops here                |
    // Lifetime must be valid here           |
    println!("{:?}", person); //             |
                           // ----- end -----+
}

The way I've "drawn" it, the lifetime is as long as all its uses, and the drop of email_2 conflicts with the lifetime being that long (still alive at the drop site). Another way to look at it is that the lifetime has to end before the inner block ends, but you tried to use person after that.

Both are different lenses on the same underlying analysis which has decided you have tried to use the reference in an unallowable way (in a way the borrow checker doesn't know how to prove is sound -- usually because it's unsound under Rusts semantics, but sometimes because it's not "smart" enough).


It's allowable in general because lifetimes don't have to be contiguous.[2] I think what's going on is that in the original example, the lifetime of the Person must outlive the reference of anything assigned to the field and thus becomes a union of other lifetimes, whereas when assigned directly it's a single lifetime in the analysis (with gaps).

But I'm becoming too brain-dead on borrow examples to want to manually walk through the NLL algorithm right now :slight_smile:.


  1. ...or vice-versa ↩︎

  2. Maybe the liveness -- subtyping -- solving constraints sections are more relevant. ↩︎

1 Like

Without needing to "run the algorithm" it should be possible to find the point where the algorithm treats actions like

person.email = &email_1;

different from

person = Person {
    email: &email_1,
}; 

right?

Arguably, it might be more surprising if they were treated the same, since that could only happen by arguing about email being the only field that interacts with the lifetime argument of Person (or in this case, it being the only field overall) which is a assertion that code can only make when there aren't any non-visible (at the point of use) private fields, since otherwise it would inspect implementation details / break if more private fields were added in the future that also utilize the lifetime parameter.

Feel free?

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.