pre-RFC: Lifetime elision 1.1 - structs with one reference field


#1
  • Feature Name: Lifetime elision 1.1
  • Start Date: 2017-03-04
  • RFC PR:
  • Rust Issue:

Summary

Let the user have the option to elide the lifetime in a reference field in a struct, if that struct has exactly one reference field in it.

Motivation

As of today, specifying lifetimes for structs with one reference parameter is useless. Consider the following example -

struct Foo {
    a: String
}

impl Foo {
    fn new(a: String) -> Foo {
        Foo {a: a}
    }
}

struct Bar {
    foo: Foo
}

Imagine now that we realized that the a field in Foo didn’t need to be owned, so we should use a 'str.

That would mean that the code now would look like this:

struct Foo<'a> {
    a: &'a str
}

impl<'a> Foo<'a> {
    fn new(a: &str) -> Foo {
        Foo {a: a}
    }
}

struct Bar<'a> {
    foo: Foo<'a>
}

Just for this simple change, Foo needed 4 lifetime annotations.

Moreso, any struct that uses Foo is now ‘poisoned’ and also need to have a lifetime parameter now.

Now, of course this change would make sense if those annotations supplied us with important information - like if there were multiple fields and we would have to know the relations of their lifetimes.

But when it comes to the case of one reference field, there are two options -

  • Use a special lifetime, like 'static - which is the special, non-common case
  • Use a generic lifetime parameter - which is the common case.

The problem is, that since there is only one lifetime parameter, the only thing that the annotation says is “this reference has a lifetime”, and without any other lifetimes to use it with, this is useless information.

If we look at this change using the parameters in ergonomic initiative blogpost:

  • Applicability - structs that contain references are incredibly common in rust because of the strong ownership gurantees.
  • Power - since in the simple cases there is only one way to fill it anyways, it doesn’t change anything.
  • Context-dependence - The lifetime annotations don’t add information and clutter the code. Removing them would make the code cleaner and won’t remove information.

Detailed design

Comparison with Lifetime Elisions 2.0

This RFC is a subset of the RFC Lifetime Elisions 2.0

While that RFC proposes many changes that change the language and syntax, this rfc only aims to get in the simplest and commonly needed change, to ‘open the door’ for struct elision changes.

The rule

This RFC proposes one rule in addition to the existing lifetime elision rules -
If a struct contains exactly one parameter that is a reference, the lifetime to this reference may be elided, and the compiler should consider this reference an unnamed generic lifetime.

Since the struct is no longer needed within the struct, it could also be dropped in the impl blocks, and in containing structs.

Otherwise, this elision rule behaves the same as all other currently implemented elision rules.

Examples:

//expanded
struct Foo<'a> {
    a: &'a str
}

impl<'a> Foo<'a> {
    fn new(a: &str) -> Foo {
        Foo {a: a}
    }
}

struct Bar<'a> {
    foo: Foo<'a>
}

//elided
struct Foo {
    a: &str
}

impl Foo {
    fn new(a: &str) -> Foo {
        Foo {a: a}
    }
}

struct Bar {
    foo: Foo
}

(NOTE: I’m not sure if Bar in this:

struct Foo<'a> {
    a: &'a str
}

impl<'a> Foo<'a> {
    fn new(a: &str) -> Foo {
        Foo {a: a}
    }
}

struct Bar<'a,'b> {
    foo: Foo<'a>,
    baz : &'b str
}

Can be safetly elided to this:

struct Bar {
    foo: Foo,
    baz : &str
}

Or should it be this:

struct Bar<'a> {
    foo: Foo,
    baz : &'a str
//or this
struct Bar<'a> {
    foo: Foo<'a>,
    baz : & str
}

It looks to me that the first example is fine, but I hope someone with more knowledge than me could clarify. )

How We Teach This

The same way we teach lifetime elision for functions - start explaining what structs are and what references in struct mean, and only introduce lifetimes when they are needed for the more complex cases.

None that this RFC answers one of the common questions begginers have, coming from languages with no lifetimes - “Why do I have to annotate the lifetime if there is only one way I can do it?”.

Drawbacks

Adds implicitness to the language - some people may prefer that any reference that is stored in a struct will always be explicitly lifetimed.

Alternatives

  • Lifetime Elisions 2.0 - Contains a lot more useful changes, and maybe we should wait to have all of them in thougether.
  • Not changing the way lifetimes work today

Unresolved questions

Can lifetimes be elided in more complex struct examples?


#2

Why do you think we should procede in this manner rather than the “2.0” RFC you linked to? This isn’t like procedural macros, the other RFC seems like a perfectly reasonable amount of content for a single RFC. This also doesn’t avoid the most major outstanding issue with the existing RFC - whether struct ellisions should be allowed to be tickless or not.

From my perspective, I don’t see that this would get resolved faster than the “2.0” RFC. I could be missing context though.


#3
impl<'a> Foo<'a> {
    fn new(a: &str) -> Foo {
        Foo {a: a}
    }
}

RFC 141 already defined elision rules that apply to this part of the code, but that part of the RFC hasn’t been implemented yet. Someone has just started working on it, so it should soon be possible to rewrite this impl block with no lifetime parameters:

impl Foo {
    fn new(a: &str) -> Foo {
        Foo {a: a}
    }
}

#4

Great points.

From what I understood of the 2.0 RFC, is that in addition to the tick syntax, one-reference-field structs should be allowed to be elided anyways, but you are still right that this is a still a point of debate.

I see this RFC being similar to the recent field init shorthand rfc - something that you expect to see in the language but just isn’t there.

The 2.0 RFC is still in the very early stages, proposes some language changes that would need time and debate to decide on, but in the mean time this specific part of it is much simpler and agreeable, and continued with the 2017 roadmap of making rust easier for beginner, I consider this to be something that should be in the language as soon as possible.

But maybe this RFC is actually premature, and you are right that the 2.0 RFC isn’t very big. I am interested to know how more people view this issue.

@mbrubeck This is great, but I do think that having the impl block elided makes the struct being not elided stick out more.


#5

I am concerned about the impact this change would have on procedural macros. @AsafMah any thoughts around how that could be mitigated? It would be good to add a discussion of this under “drawbacks.”

As a specific example, here in Serde we rely on being able to take a struct that looks like:

#[derive(Serialize)]
struct S<'s> {
    #[serde(serialize_with = "a::custom::serialize")]
    f: F<'s>,
}

… and come up with something like this within our generated code:

struct __SerializeWith<'__a, 's: '__a> {
    value: &'__a F<'s>,
    phantom: PhantomData<S<'s>>,
}

If lifetimes were elided per your proposal, the input would have been:

#[derive(Serialize)]
struct S {
    #[serde(serialize_with = "a::custom::serialize")]
    f: F,
}

… and the output would have been:

struct __SerializeWith<'__a> {
    value: &'__a F,
    phantom: PhantomData<S>,
}

… which is broken because it loses the information that F and S have the same lifetime.

Moreover, during macro expansion there is no way to know that a lifetime parameter has been elided here because that is only figured out after macro expansion happens.


#6

Huh, I was thinking that structs would benefit from the same elision that impls would get, anyway, but I guess the RFC has not specified that. :frowning:

Also, really good point from @dtolnay about the effect on procedural macros – I hadn’t thought about that yet. I guess this is where token-based macros kind of fall down, after all?


#7

I wonder what happens with the same scenarios with functions eliding lifetimes and procedural macros changing them.

I feel like this is a more general problem with any implicit syntax that doesn’t de-sugar before the macros.

In my opinion this should be for the procedural macros library in 2.0 to handle, along with other edge cases and sugars, otherwise you can’t really add any new shortcuts to the language without breaking macros that won’t know how to handle them.

I agree that in this case it is more of a problem since the error is very subtle and won’t be detectable with a normal build, and it would be in ‘drawbacks’ if the real RFC would get made.


#8

@AsafMah can you give a specific example that would be problematic because of functions with elided lifetimes? I think the answer is it doesn’t matter there. The problem I described does not apply.

I think that statement is too broad and I don’t think a procedural macros 2.0 library would easily resolve this. The more general problem is implicit syntax that requires name resolution or type information in order to de-sugar on behalf of a macro. My understanding is that those things typically only happen after macro expansion (maybe except for name resolution of macro names, in a weird way).

To demonstrate the problem with expecting a procedural macros 2.0 library to provide a solution, consider this scenario:

#[derive(Serialize)]
struct S {
    #[serde(serialize_with = "a::custom::serialize")]
    f: F,
}

macro_rules! f {
    () => {
        struct F<'s>(&'s str);
    };
}

f!();

Until after macro expansion is done, there is no F to look up to see whether it has a lifetime parameter. In general it won’t even be clear which macro invocation is going to produce a type F, if any.

There may be a solution, but I think a lifetime elision RFC should consider such cases.


#9

How about if you want to use a procedural deriving-macro with your type, then you have to write out the lifetimes, and if you don’t, the procedural macro reports an error? It’s not like you’d be forced to use elision at every opportunity. If it doesn’t mix well with procedural macros, well then don’t mix them.


#10

Alternatively elision could be limited to one level deep only, so even if the lifetime is elided, you can still see & in the struct definition.


#11

The ' requirement would avoid this problem, no?

#[derive(Serialize)]
struct S<'> {
    #[serde(serialize_with = "a::custom::serialize")]
    f: F<'>,
}

#12

Or distinguished-self style (I like having generics match with the AST generics, because you might want to write &'a Foo<'b>):

#[derive(Serialize)]
struct S<'_> {
    #[serde(serialize_with = "a::custom::serialize")]
    f: F,
}