It is recommended to increase the default lifetimes

When the lifetimes are not declared, I want the function to have default lifetimes, added to the ambiguous parameters.

When lifetimes are ambiguous. E.g:

fn first_match(s1: &str, s2: &str, regex: &Regex) -> &str;

By default matches haystack and haystack2. If lifetimes is explicitly declared, the declaration is matched.

fn first_match<'a>(s1: &'a str, s2: &'a str, regex: &Regex) -> &'a str;

Or matches all parameters to default lifetimes of return value.

fn first_match<'a>(s1: &'a str, s2: &'a str, regex: &'a Regex) -> &'a str;

It sounds like you're running into an issue with lifetimes? You're going to need to elaborate more on the case you're concerned about, because it isn't immediately obvious.

9 Likes
fn test(x: &str, y: &str) -> &str {
    "test"
}

I want the code above to also compile, like:

fn test<'a>(x: &'a str, y: &'a str) -> &'a str {
    "test"
}

Default when lifetimes are not added, This saves a lot of code.

This already exists -- it's called lifetime elision.

Your example is not covered, but arguably in the example test function this is not a good default because typically these are not the lifetimes you would want -- more commonly you would want one of these:

fn test<'a, 'b>(x: &'a str, y: &'b str) -> &'a str
fn test<'a, 'b>(x: &'a str, y: &'b str) -> &'b str
5 Likes

I mean

fn frob(s: &str, t: &str) -> &str;

Same as below code

fn frob<'a>(s: &'a str, t: &'a str) -> &'a str;

When lifetimes are not used, all parameters are matched by default

It would certainly be technically possible to add this rule.

If you wish to make that a proposal, though, it requires the same thing that the original lifetime elision rules did: a survey of the code that currently exists that would match the elision rule, and an analysis of whether that elision rule is the behaviour such code actually wants a large enough fraction of the time to be worth the extra confusion from the cases when that's not the desired elision rule.

For example,

fn first_match(haystack: &str, regex: &Regex) -> &str;

doesn't want that elision rule, as it should be

fn first_match<'a>(haystack: &'a str, regex: &Regex) -> &'a str;

in order to not tie the regex's lifetime to the haystack's.

Is that more or less common than wanting them to be the same? I don't know. The proposer of a new elision rule would have to make the case for their proposed rule.

13 Likes

Tks.

This only happens when multiple parameters are of the same type, when lifetimes are ambiguous. E.g:

fn first_match(haystack: &str, haystack2: &str, regex: &Regex) -> &str;

By default matches haystack and haystack2. If lifetimes is explicitly declared, the declaration is matched.

Regex has an as_str method and thus it's not really obvious that it should be excluded in my opinion.

Note that I'm not saying the available methods should be taken into account -- that would require the reader memorize the entire API surface of every type involved, including trait implementations. I am saying that, if expanded, elision should remain independent of the specific type constructors involved. (You don't have to return &Self to take advantage of &self.)

I don't think elision should be applied to the multiple-input case generally; I think the conversation so far already demonstrates that people have different intuitions on what it should mean.

4 Likes

They're not "ambiguous", they're elided according to the well-defined lifetime elision rules, as several people have already tried to explain.

What people are trying to say is Rust already has a rule for this. What you're proposing would be difficult if not impossible to implement without making breaking changes to those rules, and Rust already has stability guarantees.

That said, the most important case probably hasn't been mentioned: inherent methods which borrow &self:

impl MyType {
    pub fn foo<'a, 'b>(&'a self, arg: &'b Other) -> &'a str { ... }
}

The extant lifetime elision rules make it extremely convenient to return values which borrow from self, without involving the lifetimes of the other arguments.

2 Likes

The exclusion of self that I described will not affect the existing situation, because it cannot be compiled at all.

The first parameter is self and there is no ambiguity, his lifetime is self.

You have seemingly made statements that are contradictory:

So it's not clear what rule you are proposing exactly.

sorry, It's This only happens when multiple parameters are of the same type.

So, for instance this:

fn first_match(haystack: &str, regex1: &Regex, regex2: &Regex) -> &str;

would desugar to:

fn first_match<'a, 'b>(haystack: &'a str, regex1: &'b Regex, regex2: &'b Regex) -> &'b str;

?

Match the parameters of the same type by default according to the return value, that is:

fn first_match<'a, 'b>(haystack: &'a str, regex1: &'b Regex, regex2: &'b Regex) -> &'a str;

I think you're misunderstanding what the current rules are.

The example doesn't compile because the lifetime of the output is ambiguous:

error[E0106]: missing lifetime specifier
 --> src/lib.rs:3:67
  |
3 | fn first_match(haystack: &str, regex1: &Regex, regex2: &Regex) -> &str {
  |                          ----          ------          ------     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `haystack`, `regex1`, or `regex2`

Responding after edit:

This seems to contradict what you said previously:

Here there is only one parameter of type &str.

1 Like

Sorry for my misunderstanding, thanks very much

So, if I understand your proposal, you're asking to add a lifetime elision rule such that:

  • it doesn't apply in the existing cases
  • it applies when there's at least one argument whose type is the same as the output one
  • and sets the lifetime of the output to be the same as the lifetime of those arguments

While this may work in simple cases, can you show it applies in more complex ones as well? Even if it does make the current function compile, can you show it is the most correct solution in most cases? The danger is that this would make those functions compile for beginners, then they would be confused because their use doesn't work, and since the function compiled fine it should be correct, right?

For example, let's take this function:

fn test(x: &str, y: &str) -> &str {
    "test"
}

The "most correct" lifetime annotation is:

fn test(x: &str, y: &str) -> &'static str {
    "test"
}

But with your proposal it would by default be:

fn test<'a>(x: &'a str, y: &'a str) -> &'a str {
    "test"
}

Now, if someone tries to use it like this:

fn example_use1() -> &'static str {
    let s1 = String::from("1");
    let s2 = String::from("2");
    test(&s1, &s2)
}

Then this would work with the "most correct" lifetime, but would fail with yours. Similarly when the function returns only one of the parameter (and always that one!) then adding the same lifetime to the other parameter make the lifetime of the returned value overconstrained.

Overall I feel like it's bad to introduce lifetime elision rules for beginners. They are useful for very common cases because they help experienced programmers avoid putting lifetimes annotations everywhere, but beginners should be incentivized to use them to understand what they do, and only after learn lifetime elision rules that allow them to skip lifetime annotations.

4 Likes

Yes. This already complicates the language and steepens the learning curve. It means that you only encounter the need for lifetime annotations in more complicated functions, which makes it very hard to understand what they are for.

Frankly I think it would be perfectly fine if there were no elision rules at all. Explicit is better than implicit.

3 Likes

Forcing a beginner to learn all the details up-front is not a good idea. This is a great place to mention boats's dialectical ratchet. It can be a useful teaching tool for an advanced beginner or early intermediate user to write out all the lifetimes, but getting to that means one is already past the very beginner stage.

Fully "explicit", like requiring fn foo<'a>(x: &'a str) -> usize, is just completely unacceptable, IMHO. ("Explicit is better than implicit" has never been more than a vague guideline for rust design, as is subordinate to many other principles.)

4 Likes

I agree with this statement but disagree with the "dialectical ratchet" / "compiler guesses what I mean 95% of the time" programming/learning philosophy.

It's one thing to use a subset of the language that you understand (totally the right thing to do). It's a completely different thing to attempt use a feature that you don't understand, by trial and error.

Until I read the exact elision rules in the reference, this was one of the most off-putting things about Rust for me as a beginner. References were magical, I didn't really know what was going on, sometimes I got the code to work, other times the compiler was complaining about references, and I could not figure out the pattern to it. It is a very frustrating experience for me to write code by trial and error without understanding the rules. Maybe other people think differently.

I think the "try to do what the programmer means without his full understanding, thanks to some complex rules" is generally a mistake in programming language design. It was done in Cobol, in Perl, to some extent in C++ (with its complex initialization rules), and I think each time it only leads to trouble. In Rust there is less of it, but lifetime elision is an exception.

I agree, but this is an easy to understand case (&str is the same as &'_ str). I was talking about the lifetime elision rules for guessing lifetimes of output reference types based on input lifetimes.

2 Likes