Nested references and their lifetimes

I am often using a specific pattern, I dont know what it is called, I usually call it "contexts":

struct Kitchen {
    knife: Knife,
    pot: Pot,
    ...
}
struct CookingContext<'a> {
    cook: Cook,
    kitchen: &'a mut Kitchen,
}
struct CleaningContext<'c> {
    cleaner: Cleaner,
    kitchen: &'c mut Kitchen,
}
struct CuttingContext<'b, 'a: 'b> {
    cooking: &'b mut CookingContext<'a>,
    carrot: Carrot,
}
...

let mut kitchen = Kitchen::default();
let cooking = CookingContext::from(&mut kitchen); // 'a starts
let cutting = CuttingContext::from(&mut cooking); // 'b starts
drop(cooking); // 'b ends, 'a ends
let cleaning = CleaningContext::from(&mut kitchen); // 'c starts

Playground

The idea is to encapsulate sets of parameters for different operations into types. This way each operation can have only the context struct as arguments and concerns should be clearly separated.

Now the issue is that a nested context may want to use data from a larger context (for example access the knife from Kitchen in the CookingContext when in a CuttingContext, so you need references in the contexts and thus need to declare their lifetimes.

The issue now is that for nesting references, I think Rust requires me to declare another lifetime parameter for each level of nesting. That is because the larger lifetime may be different from the shorter lifetime. In the example, we use the Kitchen in the CookingContext but kitchen should live longer than cooking, for the purpose of the CleaningContext, so the reference cooking in the CuttingContext can not have the same lifetime as the reference to the Kitchen.

cooking: &'b mut CookingContext<'a>,

If I were to scale this patterns into arbitrary nesting, I would need to keep adding new lifetime parameters: DeeplyNestedContext<'d, 'c: 'd, 'b: 'c, 'a: 'b> { ... }

Is there a way to mitigate this and have the lifetime automatically inferred? Or is this simply a pattern that does not scale well in Rust? Is the pattern even any good in your perspective?

1 Like

This basically comes down to variance as it applies to nested references. A &'a T is covariant in both 'a and T, but a &'a mut T is covariant in 'a and invariant in T. One result of this is that with &mut it's a hazard to borrow something forever by making the lifetimes the same. In those situations, you do indeed want to keep the lifetimes distinct. But with a nested shared reference, where the inner lifetime is covariant, you can usually get away with using a single lifetime because a &'long &'short can coerce to a &'shorter &'shorter and avoid the "borrowed forever" problems.

If you have arbitrarily nested lifetimes with invariance...

&'a mut {
    owned_data_x,
    &'b mut {
        owned_data_y,
        &'c mut owned_data_z,
    }
}

...then you probably do need all the lifetimes.[1]

However, you could consider utilizing reborrows to flatten things out a bit...

&'a mut {                           &'a mut {
    owned_data_x,                       owned_data,
    &'b mut {                           {
        owned_data_y,                       &'b mut owned_data_y,
        &'c mut owned_data_z,               &'b mut owned_data_z,
    }                                   }
}                                    }

...either by inlining fields, or by having BorrowedThisThat<'_> versions of your struct that don't have any owned data, just &muts with all the same (covariant) lifetime and no inner lifetimes. I'd suggest the latter.

Either approach involves some amount of refactoring and/or code duplication; e.g. you may want to move most of CookingContext<'_>s methods onto BorrowedCookingContext<'_> (which CookingContext<'_> can also call).


After writing that up, I revisited your original playground to see how much you can elide in the nested approach and get away with. If you use lifetimes everywhere, not much -- but it did occur to me that you can use generics to some extent...

...with the downside that your implementation blocks become much more verbose...

impl KnifeCleaningContext<&mut CuttingContext<&mut CookingContext<'_>>> {

...but with the upside that you don't actually have to name those lifetimes, since you don't really care what they are and the outlives relationships are implicit in the nesting.


  1. In your comment you say & instead of &mut sometimes, but I'll assume you need the &mut. ↩ī¸Ž

5 Likes