Lifetime bounds correctness question

I've got a (for me) slightly unusual lifetimes problem. Usually my lifetime problems involve heroic battles with the compiler, but now I find myself in a spot where I have two versions of my code and both work without any errors, yet because I don't really know what I'm doing, I don't know which one is "more correct".

Long story short(ish): I'm making a tui-rs based app, which after some tweaking has evolved into a more or less MVC-like architecture: there 's AppState (the model), a command manager which creates commands that mutate the AppState, and a UI component that takes immutable references to AppState to draw the things on the screen.

Now, say I have some "UIComponent". It contains a subcomponent which requires a lifetime parameter (because it contains an &str, for example). It also contains references to DataRows, which comes from the AppState.

My first (working) implementation of this looked roughly like this:

pub struct UIComponent<'a> {
   subcomponent: Option<Subcomponent<'a>>
   data: Vec<&'a DataRows>
}

impl<'a> UIComponent<'a> {
   pub fn new(rows: &[&'a DataRows]) -> UIComponent<'a> {
      UIComponent {
         subcomponent: None,
         data: rows.to_owned()
      } 
   }
   ...
}

This works. As I understand it, what I'm saying here is that all references to things need to live at least as long as UIComponent. This is true for the embedded subcomponent (because it is managed wholly by UIComponent), and for AppState, since the app state is created in main, and is the "most global" struct in the app.

However, while refactoring some things today, I noticed that this might not be entirely correct, but I'm not sure. Mainly because the generic lifetime syntax still confuses me. I slap them everywhere until the compiler stops complaining and then things just work (thanks, Rust).

My thought was that, semantically at least, this somehow seems to link the lifetimes of the app state and the ui component, which seems incorrect. AppState lives way longer than any one ui component.

So, expecting to learn things by having the compiler blow up in my face, I experimented with adding more lifetime parameters:

pub struct UIComponent<'a, 'b> 
   where 'b: 'a
{
   subcomponent: Option<Subcomponent<'a>>
   data: Vec<&'b DataRows>
}

impl<'a, 'b> UIComponent<'a, 'b> {
   pub fn new(rows: &[&'b DataRows]) -> UIComponent<'a, 'b> { 
      UIComponent {
         subcomponent: None,
         data: rows.to_owned()
      } 
   }
   ...
}

Unfortunately, however, the compiler happily agreed with me and didn't give a peep. Everything just works like it did before. So now I'm wondering which one is "more correct" (or more idiomatic, if you will). I feel like I've gained more information in the actual struct definition in how the lifetimes relate to each other, but (for example) the definition of the "new" fn has become more muddled. Since subcomponent is simply initialized as None, the only lifetime information is the &[&'b DataRows] parameter, yet I still have to return a UIComponent with <'a, 'b> lifetime parameters, even if the 'a is semantically seemingly meaningless here.

Furthermore, while in the first example I could sort of reason about the lifetime of the actual UIComponent (it is equal (?) to the lifetime of the subcomponent and shorter (?) than the references to app state data), in the second example I seemingly lose some of this information, since the UIComponent is now related to two lifetimes, but obviously any UIComponent is only going to actually have one lifetime.

So essentially, my question is: which one of these implementations would be "more correct", and more importantly: why?

Lifetimes in Rust don’t really exist for objects; they’re only for references. By taking a lifetime parameter, you’re saying that the structure contains a reference with that lifetime. This has two effects:

  • Your struct must be destroyed before the named lifetime expires, so that there will be no dangling references.
  • Your struct can have methods that return references that live after the struct is destroyed, because they point directly to the data the struct referred to.

If you don’t need to take advantage of the second point here, there’s no need to specify more than one lifetime: the compiler will automatically pick the most constraining one.

The two-lifetime form, on the other hand, would let UIComponent return &’b DataRows from a method which will be able to outlive both the UIComponent and the Subcomponent’s data source. In this situation, it’s best to use meaningful names for the lifetimes that represent the ultimate owner of the referred-to data. Maybe ’app and ’sc in this case.

It does, but only for accesses through this object. Any AppState data borrowed from the UIComponent<‘a> will have to be returned before the Subcomponent’s data source becomes invalid. Note that it can survive after the Subcomponent itself is dropped, because the compiler treats it as a reference to the external data that Subcomponent was referring to.

2 Likes

Thank you, that was very helpful. It is still complicated, but a bit less confusing now. I think in the end my confusion mostly comes from the "bubbling up" of lifetime parameters. A Subcomponent<'a> containing a reference to an &'a str is clear to reason about, however a hypothetical UIComponent without any state or other references that contained only a Subcomponent now also needs to have a lifetime parameter, because it contains a struct that contains a reference. Suppose we'd place this UIComponent in a UIEngine, now that UIEngine needs to have a lifetime parameter, because it contains a struct that contains a struct that contains a reference.

This kind of reasoning gives me the same kind of headache that overly complex inheritance hierarchies give me in poorly designed OO projects. While this might be a good thing in the sense that it could signal a code smell, problems arise with the use of libraries, where your struct needs a lifetime parameter because somewhere in the things it uses is a library struct with an &str in it. Maybe this is simply poor library design, but it feels like this is a form of leaking implementation details, somehow? I don't know.

(Note that &str is a slightly special case: in practice, it is often assigned from &’static str literals, and ’static can be declared on struct members without taking it as a lifetime parameter.)

In a rough sense, you can think of a named lifetime as specifying the owner of a piece of borrowed data. If a library doesn’t want to take ownership of the strings it uses, you’ll need to have someplace else for them to live while the library is using them. In other non-GC languages, you’d have the same requirement but it wouldn’t be expressed in a way the compiler can detect.

If you think about where the strings are actually coming from, it can start to make more sense. For example, if you have an internationalizarion database that stores translations of all your user-visible strings, Subcomponent<‘i18n> means it’s relying on that database remaining active/valid, and pushing that lifetime all the way up to a UIEngine<‘i18n> starts to look more reasonable.

Usually, though, the API would be better designed if it copied the &str into a String that it manages itself. That breaks the referential dependency and avoids the need for a named lifetime. Alternatively, it could be generic and accept any Deref<Target=str>, which would let the library user decide whether &’a str, String, or Rc<str> is most appropriate.

4 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.