When is it useful to define multiple lifetimes in a struct?
Asked Answered
S

4

76

In Rust, when we want a struct to contain references, we typically define their lifetimes as such:

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

But it's also possible to define multiple lifetimes for different references in the same struct:

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

When is it ever useful to do this? Can someone provide some example code that doesn't compile when both lifetimes are 'a but does compile when the lifetimes are 'a and 'b (or vice versa)?

Sidesaddle answered 25/4, 2015 at 5:29 Comment(0)
S
46

After staying up way too late, I was able to come up with an example case where the lifetimes matter. Here is the code:

static ZERO: i32 = 0;

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

fn get_x_or_zero_ref<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
    if *x > *y {
        return x
    } else {
        return &ZERO
    }
}

fn main() {
    let x = 1;
    let v;
    {
        let y = 2;
        let f = Foo { x: &x, y: &y };
        v = get_x_or_zero_ref(&f.x, &f.y);
    }
    println!("{}", *v);
}

If you were to change the definition of Foo to this:

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

Then the code won't compile.

Basically, if you want to use the fields of the struct on any function that requires it's parameters to have different lifetimes, then the fields of the struct must have different lifetimes as well.

Sidesaddle answered 25/4, 2015 at 7:9 Comment(3)
Hahahaha! I was writing more or less the exact same thing, then had a power failure 15-ish minutes ago. I was just about to post it. Yes, about the only case I can think of is when you want to be able to take an aggregate value and split off parts of it after using it, without losing lifetime information. Think of building up a bundle of values (which might involve lifetimes), using it, then recovering the original values afterwards.Calfee
The 'b in get_x_or_zero_ref can of course be omitted since it's implied by the default lifetime elision rules.Undone
It doesn't make sense to say that a function "requires" its parameters to have different lifetimes. The purpose of lifetime parameters is to prevent the function or struct from unifying those parameters into a single (inferred) lifetime, so the borrow checker can distinguish between themTartarean
S
23

I want to re-answer my question here since it's still showing up high in search results and I feel I can explain better. Consider this code:

Rust Playground

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

fn main() {
    let x = 1;
    let v;
    {
        let y = 2;
        let f = Foo { x: &x, y: &y };
        v = f.x;
    }
    println!("{}", *v);
}

And the error:

error[E0597]: `y` does not live long enough
--> src/main.rs:11:33
|
11 |         let f = Foo { x: &x, y: &y };
|                                 ^^ borrowed value does not live long enough
12 |         v = f.x;
13 |     }
|     - `y` dropped here while still borrowed
14 |     println!("{}", *v);
|                    -- borrow later used here

What's going on here?

  1. The lifetime of f.x has the requirement of being at least large enough to encompass the scope of x up until the println! statement (since it's initialized with &x and then assigned to v).
  2. The definition of Foo specifies that both f.x and f.y use the same generic lifetime 'a, so the lifetime of f.y must be at least as large as f.x.
  3. But, that can't work, because we assign &y to f.y, and y goes out of scope before the println!. Error!

The solution here is to allow Foo to use separate lifetimes for f.x and f.y, which we do using multiple generic lifetime parameters:

Rust Playground

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

Now the lifetimes of f.x and f.y aren't tied together. The compiler will still use a lifetime that's valid until the println! statement for f.x. But there's no longer a requirement that f.y uses the same lifetime, so the compiler is free to choose a smaller lifetime for f.y, such as one that is valid only for the scope of y.

Sidesaddle answered 25/3, 2021 at 0:29 Comment(1)
tfpk.github.io/lifetimekata/chapter_5.html answers your problem well with essentially the same code snippet as yours. However your reasoning(point 1, 2, 3) is inaccurate. "so the lifetime of f.y must be at least as large as f.x" is wrong. f.y can have different lifetime than that of f.x. Only that because of there's one lifetime parameter, Rust requires v to last for the smaller lifetime of f.x and f.y.Ruble
B
13

Here is another simple example where the struct definition has to use two lifetimes in order to operate as expected. It does not split the aggregate into fields of different lifetimes, but nests the struct with another struct.

struct X<'a>(&'a i32);

struct Y<'a, 'b>(&'a X<'b>);

fn main() {
    let z = 100;
    //taking the inner field out of a temporary
    let z1 = ((Y(&X(&z))).0).0;  
    assert!(*z1 == z);
}

The struct Y has two lifetime parameters, one for its contained field &X, and one for X's contained field &z.

In the operation ((Y(&X(&z))).0).0, X(&z) is created as a temporary and is borrowed. Its lifetime is only in the scope of this operation, expiring at the statement end. But since X(&z)'s lifetime is different from the its contained field &z, the operation is fine to return &z, whose value can be accessed later in the function.

If using single lifetime for Y struct. This operation won't work, because the lifetime of &z is the same as its containing struct X(&z), expiring at the statement end; therefore the returned &z is no longer valid to be accessed afterwards.

See code in the playground.

Bettis answered 19/9, 2019 at 20:55 Comment(7)
The additional lifetime to Y can be removed if the expression X(&z) is lifted into its own variable. i.e. let x = X(&z). play.rust-lang.org/… Is there another way to force the need for additional lifetime parameters? I'm currently trying to understand why functions might require >1 lifetime parameter.Reel
@StevenShaw Yes. A separate variable x will lift X(&z) to the same scope level as z, instead of a temporary within z's constructor. On the other hand, the case in my answer is not a game of concepts, but happened in my actual project. I just reduced it into the given code. For functions, it is even more common to have more than one lifetime parameter. E.g., you have two input borrows, but the return value's lifetime only relies on one of the inputs lifetimes.Bettis
Thanks, I thought it might be that I'd only see it in a wider context. I've tried hard to come up with a small example that requires multiple lifetime parameters on a function. For example, the accepted answer can simply have the second parameter to the function removed. It can even have the second parameter to the struct removed if you also remove the unnecessary scope in main. play.rust-lang.org/… I've tucked away your nice phrase "game of concepts" and added your book to my wish list.Reel
@StevenShaw Being able to remove the lifetime parameter of the second input (while keeping the first one) already means they have two different lifetime arguments. It is just that one is elided according to "lifetime elision" rule. Secondly, the inner scope for v in main() in the accepted answer can be a function call (or call chain), hence cannot be simply removed.Bettis
Got it. My deletion does rely on lifetime elision (all variables have lifetime tracking in Rust if I'm not mistaken). I'm looking for an example where it's necessary to annotate multiple lifetimes on a function (where elision does not work).Reel
@StevenShaw Lifetime elision does not work when the compiler cannot infer its lifetime. A simple example is to let the function to return a struct that has two different lifetimes such as Foo in the accepted answer. See play.rust-lang.org/… .Bettis
Thanks for your help and perseverance! So, functions require multiple lifetimes when structs require multiple lifetimes. This brings us back to this very SO question! However, I find the answers here pretty contrived as I can remove the lifetimes from the structs without any trouble. For instance, I change your lastest playground to have a single lifetime parameter (and also the function). play.rust-lang.org/…. Again, I a imagine that in a larger project, multiple lifetimes on structs must become necessary.Reel
E
2

Multiple lifetime parameters because input is nested lifetime parameter

struct Container<'a> {
    data: &'a str,
}

struct Processor<'a, 'b> {
    container: &'a Container<'b>,
}

fn process<'a, 'b>(processor: &'a Processor<'a, 'b>) -> &'b str
where
    'a: 'b,
{
    processor.container.data
}

fn main() {
    let data = "Hello, world!";
    let container = Container { data: &data };
    let processor = Processor { container: &container };
    let result = process(&processor);
    println!("Result: {}", result);
}

Return type with shorter lifetime

I think it is worth mentioning that lifetime parameters in Rust are closely tied to the scopes in which they are defined. By assigning appropriate lifetime parameters and adding constraints, we ensure that references have valid lifetimes and avoid issues with dangling references or references outliving their intended scopes.

Each item or reference has its own lifetime, which determines how long it is valid and can be used. When we specify different lifetime parameters for different items, we are giving each of them a "ticket" that represents the duration for which the reference is valid.

Consider the following code structure:

{
    // Scope of item A
    {
        // Scope of item B
    }
}

Item B can only live within its scope, while item A can live in both the scope of item A and the scope of item B.

In the code above, the get_x_or_zero_ref function has two input references with different lifetimes: 'a for x and 'b for y. By giving each of them a lifetime parameter, we are essentially giving them their own "tickets" that specify how long the reference is valid to avoid dangling references.

Now, when we specify the return type of the get_x_or_zero_ref function as 'a i32, we are saying that the lifetime parameter for the return type should be the same as the lifetime parameter for the first input reference, which is 'a. This ensures that the returned reference will not outlive the scope of x.

But what if we want to specify the lifetime parameter for the return type as 'b i32 or -> &'b i32 or that has a shorter lifetime instead?

In this case, we are trying to assign the lifetime parameter of 'b to the return type, which corresponds to the scope of item B. However, there is a potential problem if we don't handle it correctly. The lifetime of item B will end within its scope, but the lifetime of item A, by default, has a greater scope than that of item B.

This is problematic because we specified the return type to have the lifetime 'b, which corresponds to item B's scope. However, the lifetime of item A, represented by 'a, outlives the needs of the return type. We are essentially giving the return type a ticket that is valid for a shorter duration than the actual lifetime of item A.

To resolve this issue, we need to add a constraint to the 'a lifetime of item A, indicating that the tickets associated with 'a are only valid within the scope of item B. By adding this constraint, we ensure that the returned reference does not outlive its intended scope and maintains the correct relationships between lifetimes.

So if we want to return an item that has a shorter lifetime, we need to determine if there is another input with a greater lifetime. Then we add a constraint on the input with a greater lifetime so its tickets are only valid within the shorter lifetime and don't outlive another.

Here is the code if we want to write the return type to have a shorter lifetime.

static ZERO: i32 = 0;

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

// returning lifetime 'b
fn get_x_or_zero_ref<'a, 'b>(x: &'a i32, y: &'b i32) -> &'b i32
where
    // Adding constrain so a will not outlive b
    'a: 'b,
{
    if *x > *y {
        x
    } else {
        &ZERO
    }
}

fn main() {
    let x = 1;
    let v;
    {
        let y = 2;
        let f = Foo { x: &x, y: &y };
        v = get_x_or_zero_ref(&f.x, &f.y);
        println!("{}", *v);
    }
}

Estrin answered 6/7, 2023 at 1:18 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.