In the meeting we discussed the general state of the impl Trait, with a particular focus on “what are the open questions that would prevent us from stabilizing?”
General status
@eddyb completed the implementation of the “most basic form” of impl Trait. In particular, you can use impl Trait in free function return types. This has already raised a couple of interesting issues that we didn’t uncover in the RFC process itself (covered below).
Looking forward, there are also a number of (relatively) straight-forward extensions that we’d like to see:
-
impl Traitin trait method return types- this would effectively desugar to an associated type in the trait
- but this associated type would need to be generic, along the lines of RFC 1598
-
impl Traitin argument position- this would be sugar for a generic argument
-
fn foo(x: impl FnMut())becomesfn foo<T: FnMut()>(x: T)
-
- this raises syntactic questions (see below)
- this would be sugar for a generic argument
And other possible extensions that one could imagine, but where the desirability and semantics are less clear:
-
impl Traitfor local variables -
impl Traitin structs
Question: what should be captured by an impl trait?
One of the more interesting questions that arose had to do with “capturing”. If you have a generic function, the current design allows the “hidden type” described by the impl Trait to make use of any type variables it wishes. It turns out that this is not always what you probably want.
Let’s explore through an example. Consider https://is.gd/Cn6iAN. Here we have a function foo() that returns a pair (T, U), though the caller only sees impl SomeTrait:
fn foo<A: Debug, B: Debug>(a: A, b: B) -> impl SomeTrait {
(a, b)
}
The caller main() can invoke foo like so:
fn main() {
let mut i = 0;
let mut j = 0;
let k = foo(&i, &j);
// Are these legal?
// i += 1;
// j += 1;
k.print();
}
You’ll find that if you uncomment i += 1 or j += 1, the program will not compile. This is because k could potentially be using &i and &j, so we have to keep those two variables locked so long as k is in scope. (And, in this case, it is.)
But of course the same is true regardless of the body of Foo. So if we update Foo to only use the first argument:
fn foo<A: Debug, B: Debug>(a: A, b: B) -> impl SomeTrait {
(a, ) // look ma, no `b`
}
we will still get the same errors in main (and rightly so; we don’t want to leak internal impl details).
But sometimes we will want to take “transient” arguments that are not used in the return type! And this is particularly evident when those arguments are lifetime parameters. Imagine if instead of foo we had something like this (lifetimes made explicit for clarity):
impl SomeType {
fn iter<'a,'b>(&'a self, config: &'b Config) -> impl Iterator<Item=Blah> { ... }
}
Now if you call x.iter(&config), this means that config will be locked during the entire iteration. This is because we assume that the return type may “capture” 'b.
Note the analogy to lifetime elision. If you have the same function signature, but you have a reference in the result:
impl SomeType {
fn iter(&self, config: &Config) -> &SomeConcreteIteratorType { ... }
}
Here lifetime elision would expand the return type to use the same lifetime as &self. This is because experimentally we found that this is what you want most of the time. Note that we do not expand to the same lifetime in all three positions:
impl SomeType {
fn iter<'a>(&'a self, config: &'a Config) -> &'a SomeConcreteIteratorType { ... }
}
If we did so, then this would be more analogous to our impl Trait behavior – i.e., this would mean that, by default, config is locked as long as the result is used, just as is the case with impl Trait.
So, the first thing we noted is that whatever we do, we will probably need some form of explicit syntax (just as with named lifetime parameters). No default will be right 100% of the time. So we propose the syntax impl<'a,B> Trait where the 'a and B refer to lifetime or type parameters in scope that may be captured. If you don’t have any <> (e.g., impl Trait), then this applies the default (yet to be determined). If you have an empty <> (e.g., impl<> Trait), this implies no capturing at all. The fact that an empty <> has a different meaning than no <> gives some mild discomfort.
Next point is that we need to gather up data from what really happens in practice to determine the best default. It may be that the current behavior is best, but maybe not – or maybe we want a default that is different for type vs lifetime parameters. Thoughts?
Question: how to extend to argument position?
Most everyone in the lang team would like to see impl Trait usable in argument position as a shorthand for declaring a type parameter. This is for several reasons. First, it’s a way to lighten notation significantly. Second, it may allow one to teach traits earlier without going the details of explicit parameters and parametric polymorphism. However, it does raise some questions. Because impl Trait would be expanded differently in argument position, is it proper to use one keyword?
Many have argued for a distinction where the current impl Trait (in return position) would be some Trait, indicating that a specific type is being returned, even if it is not explicitly named, whereas the impl Trait in argument position would be any Trait. For example @withoutboats is a proponent.
On the other hand, it’s unclear how important this distinction will be in practice, and it could be confusing. An alternative would be to have impl Trait that is a contextual shorthand, and then have explicit named parameter notation for both return position (which we lack today) and argument position (which we have). I prefer this, at least at present. =) This version does open the door that we might someday change the meaning of Trait, though that opens up another can of worms best left out of this thread.
It’s worth noting that there are cases where one might want some Trait even in argument position (and if we only had impl Trait, we would not handle said cases correctly). An example would be fn foo(x: any FnMut(some Display)), which is saying “give me a closure that is prepared to handle a type T: Display but I’m not telling you what T is”. @wycats has described this using the keyword “my”, which I think gives a better intution: fn foo(x: any FnMut(my Display), meaning "foo gets to decide the type of Display value you are given".
Clearly, before we stabilize impl Trait notation, we should settle on whether we will want to use the keyword some (or, no pun intended, some other alternative) instead.
For the record, I don’t like the keyword some in particular because I think it is begging for confusing with the option variant Some, also a new concept to many early Rust users.
What should block stabilization?
It seems clear that we have to settle on a keyword and capture semantics before we could stabilize, since any changes there would break code. But what about implementation status? How “complete” does the story have to be?
For example, @aturon felt like he would want at least argument position working before stabilizing, but that we didn’t have to have things working in traits. Otherwise the feature “feels too incomplete”. @nrc felt like he wants impl trait yesterday and would rather stabilizing bits and pieces as they come into being. This was the area where the meeting ended without a clear consensus.
Focus time!
So the key questions are:
- What do we need to fully settle before stabilizing
- default capture semantics
- keyword we want
- anything else?
- What do we need to implement before stabilizing
- obviously capture semantics must be right
- in particular, do we need argument position?
- trait position?
- anything else?