Why no lifetime elision for structs?

I'm curious why an explicit lifetime must be specified in this case:

struct Inner {
}

struct Outer<'a> {
    inner: &'a Inner
}

I get why a lifetime is required, obviously the reference and the Inner object it refers to cannot go out of scope before the Outer object. By why can't the compiler automatically make that deduction from the next piece of code?

struct Inner {
}

struct Outer {
    inner: &Inner
}

Which results in the message:

error[E0106]: missing lifetime specifier
  --> main.rs:45:12
   |
45 |     inner: &Inner
   |            ^ expected lifetime parameter

At least at first glance it seems pretty obvious what the lifetime requirements are (in this case). But it seems that lifetime elision is only for function signatures and not structs. Why is that?

2 Likes

From https://github.com/rust-lang/rfcs/blob/master/text/0141-lifetime-elision.md#lifetime-elision-in-structs

We may want to allow lifetime elision in structs, but the cost/benefit analysis is much less clear. In particular, it could require chasing an arbitrary number of (potentially private) struct fields to discover the source of a lifetime parameter for a struct. There are also some good reasons to treat elided lifetimes in structs as 'static.

Again, since shorthand can be added backwards-compatibly, it seems best to wait.

It's important to remember that at the time, lifetime elision was extremly controversial. Like many things that have controversy, reducing scope is a good way to get more people on board. As such, any additional elision was considered worth get rid of to land what we do have.

Of course, times change: elision isn't particularly controversial today. So this could change. A common solution would be to let you write

struct Outer<'_> {
    inner: &Inner
}

This &_ would assign the same lifetime to all lifetimes inside of the struct, while still preserving the information that it contains a reference, which is significant.

Making this change would require a new RFC, but nobody has done so. Someone could, but most find the boilerplate to not be worth the effort, I guess.

11 Likes

Thanks for the answer. Maybe something for the future then.

Please, no more superfluously overloaded syntax. In particular, let's not overload _ to mean sometimes "unused thing" and "implicitly used thing." Also, it would be unfortunate to have two different syntaxes for elision, one for functions and one for types.

I agree with the OP that it makes a lot of sense to just default omitted lifetimes in the way OP suggested. One question is what to do when there are some explicit lifetimes? I think it makes sense to have a rule "either they are all elided or they are all explicitly specified."

One way of looking at this is that we want to encourage Typeful Programming. In order to do that, defining, using, and converting to and from types must have low friction. Part of that is reducing how much one needs to type to define a type. Fully omitting any lifetime syntax for elision meets this goal better.

4 Likes

This is just some strawman syntax, not even a formal proposal. Any change would have to go through RFCs like anything else.

_ is already used to mean "please infer this type", like in

let v: Vec<_> = something.iter().map(|x| x + 1).collect();

So the idea would be that '_ would be "please infer this lifetime".

The main reason the '_ was suggested is that it's important to see that your structure is parameterized over a lifetime in the syntax. If you see Foo, you know it's owned. Foo<'a> currently lets you know it contains lifetimes. Doing OP's suggestions means Foo could mean both, whereas Foo<'_> would still give you that info.

Agreed.

7 Likes

Ah, yes. I forgot about that. Thanks.

I'm not convinced it is necessary to be so explicit regarding that, especially since the "&"s in structs' fields' types aren't that hard to see.

Probably it worth figuring out how worthwhile elisions are vs. relying on IDE support. A good IDE may make elisions of all sorts a net loss because the IDE could easily add the lifetime annotations in one keystroke or even automatically.

They could be indirect through nested structs, so a totally elided lifetime might not be obvious at all.

7 Likes

Why does it need to be obvious? In a situation where

Struct A containing a reference to Struct B containing a reference to Struct C containing...

it is still the case that the outer struct must have a lifetime <= to the inner lifetime.

In my opinion, a syntax of struct A<'_> still feels like I'm typing something that should be "obvious" to the compiler. I'd prefer that if no lifetimes are specified it is interpreted to mean one lifetime which should apply to all inner references of the struct. And if that doesn't compile, manual lifetimes should be required (just like now).

After all, if struct A wouldn't compile but struct A<'_> would, why not remove the case which doesn't compile anyway and make it do so.

2 Likes

The issue is documenting it to other users not to the compiler.

We could easily add ellisions to structs that would cover every or nearly every case. The compiler would handle that just fine. The issue is that in the case of a struct, which you move around (unlike a function, which you just call), whether or not a struct is constrained to some lifetime matters a great deal.

If we didn't require you to forward those lifetimes to the top of the type, the references may be nested in the private fields of private inner structs which are private fields of this struct, or even deeper, and a user would have to actively trace the whole layout of the type to find out if it were 'static or not. Indeed, the type with a reference inside of it might not even be in the same crate as the type you're wondering about the lifetime of.

EDIT: To be clearer, the issue isn't A contains a ref to B contains a ref to C, the issue is A contains B by value, B contains C by value, and C contains a reference. But C is in a transitive dependency and B isn't even a public type, so you have to actively trace the source of multiple crates just to find out if this type is static or not, whereas right now the top level type will tell you.

7 Likes

Hmm. That's a very good point. Doing lifetime elision here could introduce peculiar corner cases which would be much more annoying to understand and fix than having to specify the lifetimes in the first place.

If the problem is the typing, or the remembering to type, the lifetimes, that's easily solved by your IDE adding an "infer lifetimes" refactoring. Similarly there could in theory be a similar "infer function signatures" refactoring to approximate something like global type inference. Note that even a cargo command could fill the role of an IDE insofar as filling in missing lifetimes and types.

Remembering back to when I wrote a lot of Java code, now I couldn't imagine writing Java code without IntelliJ IDEA since it practically writes all the boilerplate for me. And I think that's the case with Rust too.

You are right that it isn't obvious. However, I think the concern may still be overblown.

I have written quite a bit of code in languages with global type inference where we actually used the global type inference quite a bit. Global type inference has very similar issues, plus worse ones, but in practice it was productivity-enhancing to have to think/type less when writing the initial code. In the end, we usually went back and added explicit type signatures to the code after it had become more stabilize, for documentation purposes and for other reasons.

I think explicit lifetimes in Rust are probably similar: they're probably good style, for easing future reading and maintenance, but probably far from essential and probably even counterproductive in many cases.

Still, this conversation and researching more about it has mostly convinced me that most inferences like this should probably just be done by the IDE and/or equivalent.

1 Like

The problem is for me, most of the time, it doesn't matter. If it works, I don't have to know. If it doesn't work, then the compiler error will tell me what I need to know.

If it's optional I still have an option to make the lifetime explicit, e.g. in a public API. But I'd like ability to have it elided, so that I can add references to existing no-references struct without tediously adding lifetime boilerplate to every. single. use. of the struct, and everything that contains it, and every function...

To me, being able to add a field to a struct, even just to try some alternative solution for 5 minutes, without having to edit lots of other files, matters a great deal more.

1 Like

I think this sounds worthwhile, I definitely recognize going through this.

We need to do this experiment in detail, does this work out in real world situations and how often does it do that, that elision is the right thing across all trait impls, and other places the lifetime parameter could be used?

This post was about eliding the lifetimes in the declaration of the struct. It does not mean the lifetime would be able to be elided in every use of the struct.

1 Like

Note the code

#[derive(Debug)]
struct Val1<D: Debug>(D);

#[derive(Debug)]
struct Val2<'a>(&'a dyn Debug);

#[derive(Debug)]
struct Val3(&String);

fn main() {
  let st = String::from("1234");
  let val1 = Val1(&st);
  let val2 = Val2(&st);
  let val3 = Val3(&st);

  println!("{:?}", val1);
  println!("{:?}", val2);
  println!("{:?}", val3);
}

If the compiler already solves the lifetime in Val1, why not for Val3 ?

Please don't revive a 3 year old thread. Open a new one if you wish to ask a question, and link it to this one.

Thanks.

The title of the new post would be the same!

Then change it. Perhaps try "Why is there no lifetime elision for structs"?

I can see where you're coming from, but allow me to present an alternative, more general view: _ simply means "I don't care (about the name)". This interpretation is valid for patterns of all kinds (eg in match exprs), for lifetime elision on structs, in generics, in fns, and more.

1 Like