Implicit Lifetime Generic Arguments

Hopefully this is the right category... it's unclear where to ask questions about the language itself.

I am reading the book again (3rd time, maybe?) and am on the generics section on lifetimes: Validating References with Lifetimes - The Rust Programming Language. This time around I asked myself, "Why do we need to explicitly list lifetimes in the generic <> lists?"

In other words, why can't the compiler see 'a, 'b, 'c, etc. and automatically bring these into scope?

So, say you have a function:

fn longest(first: &'a str, second: &'a str) -> &'a str {
    // ...
}

Today this won't compile because 'a isn't declared anywhere. Similarly, with structs this won't compile either:

struct Wrapper {
    inner: &'a str;
}

My question is why couldn't the compiler just add the 'a to the generic argument list automatically to save us some typing? Is this something others have brought up?

It's the same situation like:

fn main() {
   a = 5;
}

The compiler could just guess you want to declare a variable a, but it doesn't. It's intended to make sure you know you're declaring a new lifetime, and not just have a typo or a syntax error.

1 Like

I like your analogy. I wonder in how many situations declaring a lifetime implicitly would lead to surprising results, though? When languages allow you to initialize a variable without explicitly declaring it (Python???), the net result is usually operations silently do nothing.

Most of the time I see lifetimes needing to be specified (and I am very new to rust, mind you), it's to claim two references have overlapping lifetimes, not independent life times. Having two lifetimes usually leads to compiler errors, in other words.

I wasn't sure my question was worth asking, tbh. Before I killed it off permanently, I thought I would just put out that last bit of reasoning. In other words, are there times when having multiple lifetimes (due to a typo) is less likely to result in the compiler yelling at you?

I have the entirely opposite stand point. I feel like even the existing suggestion to add lifetimes is suboptimal. (Hence, adding them automatically implicitly would be quite terrible.)

If I write

struct Wrapper {
    inner: &str,
}

I get

help: consider introducing a named lifetime parameter
  |
1 ~ struct Wrapper<'a> {
2 ~     inner: &'a str,
  |

however, in Rust, defining a struct with a lifetime argument should be a very deliberate decision. I’ve seen countless accounts of people running into follow-up problems because the lifetime arguments on their structs become too complicated.

Even if it works it can be more tedious than necessary; e.g. also getting to the point of getting your program to work can sometimes be quite some effort, especially for less experienced Rust users. I believe that borrow-checking in Rust really only gets actually “hard” once you start putting references into other data structures; and thus putting owned types, without lifetimes, into struct fields should generally be the preferred approach, at least in higher-level Rust programming.

I would thus appreciate if we could somehow improve the error message so it suggests multiple way of addressing the problem; adding a lifetime parameter is one, but the approach of choosing a different, owned, type should be another suggestion, too.

5 Likes

Interesting. I'm too new to Rust to have a standpoint, ha.

So you're saying the compiler should not only suggest introducing a generic argument but also suggest using an owned type as an alternative as well. Make sense.

Do you have any feedback regarding functions, then? Is it the same, for consistency's sake?

The important point is that when you didn’t intend to add a lifetime argument to a type, at the type’s use-sites, that will likely rely on the fact that the type doesn’t involve any borrowed data with lifetimes, you will get compilation errors. These errors will then point at the wrong place, namely the use-site, even though it would have been the type definition that you messed up (intention: no lifetimes, reality: there are lifetimes).

I can only guess, but is this about lifetime elision in function signatures? The situation there would be different. Especially with elided_lifetimes_in_paths lint on (which hopefully becomes default some day), lifetime elision is not about hiding lifetimes entirely, but instead it’s just to avoid the need to name them.

In the context of lifetime elision, indeed something like fn foo(x: &i32, y: &mut Vec<&i32>) would introduce two independent/different lifetimes for the &i32s, whereas operations such as calling y.push(x) inside of the function may require the two lifetimes to be the same.

Here, I’d say the requirement to be explicit about the latter is less about different lifetimes being a “safer” default and the need to point out lifetimes to be related in a certain manner to be something that helps prevent bugs or unintended actions. Instead, elided lifetimes should be considered “explicit”, too, and the precise elision Rules of Rust are relatively straightforward and should be learned in order to correctly understand elided lifetimes. It’s mostly truly a tool to save typing in this case. (Yeah… the rare feature where Rust actually cares about “saving keystrokes” if you will; usually not the most valued thing in our language.[1]) However, the situation is a bit different in that functions that are generic over lifetimes are a lot more common than structs generic over lifetimes, so it isn’t encouraging anything bad.

With the view that function signatures are always implicit, we still follow the principle of explicitly declaring what’s wanted at the function definition site – this time this explicitness (even if it involves explicitly eliding the lifetime name) serves to have a good, well-defined, boundary for abstracting over implementation details and for allowing local reasoning.

That being said, Rust beginners do commonly get function signatures wrong, in particular if they get it wrong in a way that makes the function itself still compile, but breaks the caller, because some lifetime is declared too restrictive. (On that note, maybe that is actually a good argument in favor of elision only introducing unrelated lifetimes for parameters.) On that point, I’d also appreciate if future Rust versions can be better at suggesting improvements to function signatures. E.g. if Rust gained the ability to infer some “minimally restrictive” function signature that still works for the function body at hand, then it could use that to suggest “maybe relax the function signature as follows […]” when errors at the call-site occur.


  1. As mentioned above, elided lifetimes can be easy to read, too, not just easy to write, if you’ve learned the basic elision algorithm. Although some corner cases can be confusion and should be avoided:

    • anything that elided_lifetimes_in_paths lints against is super hard to read, as the lifetime becomes truly invisible
    • the ability for elided lifetimes to be the same as some other named lifetimes isn’t really nice, so you probably don’t want to write
      fn foo<'a>(x: &'a i32) -> &i32 {
          x
      }
      
      where the return value’s lifetime will be 'a according to the elision rules.

    Another “fun” quirk of elided lifetimes, and where I’m not so sure how good or bad I should really consider it is the special case of fully-elided trait object lifetimes. Just mentioning this here :slight_smile: It’s designed in a way that quite successfully allows you to ignore the existence of this particular lifetime in very many cases, but that makes it all the more confusing in cases where you do run into issues from it. ↩︎

1 Like

Changing lifetime elision comes up from time to time, and is a topic there are a lot of strong opinions about. You can find a lot of prior discussion, for example by following some of my links that follow.

Not having to declare function lifetimes in particular was accepted as a concept, then rejected before stabilization.

If nothing else, read this.

Back in the heyday of the Ergonomics Initiative, it was popular to refer to pre-vs-post rigorous learning. Most "ergonomic" and "simpler" arguments appeal to the pre-rigorous stage. And similar logic is frequently used to argue that adding more inference in favor of omitting explicitness / optimizing for writing in the language.

However, in my opinion, this approach is problematic when it actively makes things worse for those with a rigorous understanding. Whether or not you agree that this actually harms per-rigorous programmers or not,[1] I feel lifetime declaration elision is way too harmful to those who have reached a non-beginner understanding and appreciation of lifetimes.

Being able to type less is not really "ergnomic" or good language design when you end up obscuring critically important information. You may not appreciate knowing which lifetimes are local or not today, but if you become experienced enough tomorrow, you will.

That's the short version; below I write way too much more.[2] You may find it interesting, but you could also just skip it.


RFC 2115 is the kind of a mega-RFC[3] about lifetime elision which was accepted, partially implemented, and partially unaccepted.[4] Some parts of the RFC are

There's an interesting mix here of having to type less and having to be more explicit:

 // In-band lifetimes (unaccepted)
-fn foo<'a>(a: &'a str, b: &str) -> &'a str { a }
+fn foo    (a: &'a str, b: &str) -> &'a str { a }

 // `elided_lifetimes_in_paths` (allow by default)
 struct Bar<'a>(&'a str);
-fn bar(a: &str) -> Bar     { Bar(a) }
+fn bar(a: &str) -> Bar<'_> { Bar(a) }

And the reasoning was along the lines that naming lifetimes without declaring them was still clear enough yet more ergonomic and easier to understand, whereas complete lifetime elision with no & sigil is too confusing and should never have been allowed, because whether or not a return value (or type more generally) is borrowing something often matters a lot.

The counter-arguments were that lifetimes coming out of nowhere is more confusing, harder to read, and also opens a big can of worms with regard to accidentally using a lifetime from a prior scope, accidentally introducing a new lifetime, or accidentally conflicting with or changing the meaning of far away code -- i.e. the typical problems of "implicitly declared because you used it". There were various things suggested to mitigate some of the problems, like sigils or naming conventions to segregate implicitly declared and explicitly declared lifetimes, but none stuck.

Being able to have structs implicitly generic over lifetimes wasn't part of the accepted RFC,[10] but it was discussed as an option for single-lifetime structs during the lead-up. Removing all or most visual clues that a type borrows would not have been acceptable under the reasoning above, but maybe there could have been

struct Bar<'_>(&str);

It was probably limited to single-lifetime structs because when you have multiple lifetime-parameterized fields, deciding whether they have the same lifetime or different lifetimes is something you should give deliberate consideration (and changing your mind is a breaking change). It's analogous to the function lifetime elision we do have only working when there's a single input lifetime.

Personally I'd be fine with the single-lifetime version.[11] The only thing I'm thinking of that it encourages programmers to do worse off-hand is already something almost no-one knows about or thinks about.


Anyway, that's some history; here I mostly dump my personal opinions.

My view on RFC 2115 is that it tried to take on too much at once, it was based too much around personal preferences, and it was way too rushed because some people wanted it to be part of edition 2018.[12] Some good things did come out of it:

  • '_ is an almost[13] 100% net positive IMO, and has applications beyond elision; it's rare for a Rust "ergonomic" "improvement" to be this successful

  • I use elided_lifetimes_in_paths all the time and hope momentum to enable it (at least at the warn level) returns despite the larger RFC-unacceptance

But I'm in the camp that implicit declaration of lifetimes is a very bad thing, so I'm glad the in-band portion of the RFC died. I also disagree with the arguments that it streamlines learning or makes things any simpler. Omitting critical information or even making it less visible does not make things any simpler. It makes it visually less complicated, but actually reasoning about what goes on, or sometimes behavior itself, becomes more complicated.

If you have a function signature that requires naming a lifetime, where that lifetime came from is critical. Moreover, having fn foo</* named lifetimes go here*/> in particular is valuable because it gives you a place to look; you don't have to read the signature and guess.

I also feel that admitting the feature needs some way of segregating local and non-local named lifetimes (case, length, extra sigil, whatever) is half-admitting that this is, after all, about (a) optimizing writing over reading or perhaps (b) some war against <...>. Once you have such segration, you're not longer removing the declaration; you're just presenting a new way to signal it. One that just doesn't stand out so much. One that is easier to miss... which is a demerit for critical information.

I say half-admitting because I recognize that there legitimately is some group of people out there who are really thrown off by the presence of <...> and earnestly believe it makes things less understandable. Or at least, that's the most charitable conclusion I've come to. In any case, some people just really, really want to make <...> go away.[14] Personally I don't find it a problem at all, and sometimes it's a benefit.


  1. the link above argues it was a loss for both camps ↩︎

  2. Even though I've deleted like 80% of my tangential rambling and ranting... ↩︎

  3. usually a bad idea as it generally leads to riders IMO ↩︎

  4. There's a lot more RFC-PR-style discussion in the tracking issue too. ↩︎

  5. unaccepted ↩︎

  6. implemented ↩︎

  7. implemented but still allow by default ↩︎

  8. implemented but still allow by default ↩︎

  9. abandoned? ↩︎

  10. no idea why not ↩︎

  11. but probably not any multi-lifetime version ↩︎

  12. Hopefully that last one is less common with editions being less exceptional. ↩︎

  13. it has some niche inconsistencies and I'm not sure how fixable they are; TBH they might have existed with named or normally elided lifetimes pre-'_ too ↩︎

  14. cough APIT cough ↩︎

4 Likes

I appreciate the time you put into this. Thanks for the reading material - I will check it out. It feels better knowing this has been a hot topic and people who know Rust better than I do have given it serious consideration.