[lang-team-minutes] Elision 2.0

I was picking back up on this topic today, and together with folks on #rust-lang, had a couple of realizations.

First up: the idea of e.g. Ref<&, T> has some issues around impl Trait. The proposal was to use impl& Trait, but the fact that &impl Trait (and hence &impl& Trait) is a thing, with a somewhat subtly different meaning, seems worrisome.

But in digging into this, @mbrubeck raised a really interesting idea.

Part of the last merged RFC on impl Trait talks about the interaction with lifetimes. The key question is how to understand signatures like the following:

fn iter1(&self) -> impl Iterator<Item = i32>
fn iter2(&self) -> impl Iterator<Item = &i32>
fn transform(iter: impl Iterator<Item = u32>) -> impl Iterator<Item = u32>

In particular, each of the return types will actually be some concrete, underlying types. What lifetimes are allowed to appear in that type? Can iter1 produce an iterator that borrows from self? Can transform produce an iterator that mentions lifetimes if its argument did?

The RFC makes a key assumption here, which I want to argue is false:

There should be an explicit marker when a lifetime could be embedded in a return type

This is in the context of the general regret over elided lifetimes in type constructors having no marker that they occur (making it hard to know when borrowing is happening), which has been discussed throughout this thread.

Here's the thing: we can achieve the same goal by using reversed defaults! That is, the general assumption can be that impl Trait allows for borrowing according to the usual elision rules, making impl much like & when scanning for borrowing. When elision isn't allowed, or when you want to override these rules, you can impose lifetime bounds. That is:

// these two are equivalent:
fn iter1(&self) -> impl Iterator<Item = i32>
fn iter1<'a>(&'a self) -> impl Iterator<Item = i32> + 'a

// by analogy to the following:
fn iter1(&self) -> &SomeStruct // the lifetime here is tied to self's
// similarly, these two are equivalent
fn iter2(&self) -> impl Iterator<Item = &i32>
fn iter2<'a>(&'a self) -> impl Iterator<Item = &'a i32> + 'a
// finally, these two are equivalent:
fn transform(iter: impl Iterator<Item = u32>) -> impl Iterator<Item = u32>
fn transform<'a, T>(iter: T) -> impl Iterator<Item = u32> + 'a
    where T: impl Iterator<Item = u32> + 'a

In cases where elision rules don't apply, you have to disambiguate:

// This is not allowed:
fn no_elision(x: &Foo, y: &Bar) -> impl SomeTrait

// Instead, write e.g.
fn no_elision<'a>(x: &Foo, y: &'a Bar) -> impl SomeTrait + 'a

The point is that, unlike with custom type constructors, with impl you assume borrowing (based on elision) unless otherwise stated. That makes borrowing fully apparent based on the signature, but is likely a better default -- and it eliminates the need for something like impl& to flag that borrowing might be happening.

What do you think?

5 Likes