Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Permit impl Trait in type aliases #2515

Open
wants to merge 17 commits into
base: master
from

Conversation

Projects
None yet
@varkor
Copy link
Member

varkor commented Aug 5, 2018

Allow impl Trait to be used in type aliases, resolving the open question in RFC 2071 as to the concrete syntax for existential type. This makes it possible to write type aliases of the form:

type Adder = impl Fn(usize) -> usize;
// equivalent to: `existential type Adder: Fn(usize) -> usize;`

Rendered.

Thanks to @rpjohnst and @Centril for their ideas, discussion and feedback.

@Centril Centril added the T-lang label Aug 5, 2018

In addition, when documenting `impl Trait`, explanations of the feature would avoid type theoretic terminology (specifically "existential types") and prefer type inference language (if any technical description is needed at all).

## Restricting compound `impl Trait` trait aliases
The type alias syntax is more flexible than `existential type`, but for now we restrict the form to that equivalent to `existential type`. That means that, if `impl Trait` appears on the right-hand side of a type alias declaration, it must be the only type. The following compound type aliases, therefore, are initially forbidden:

This comment has been minimized.

@Nemo157

Nemo157 Aug 5, 2018

Contributor

My biggest issue with this restriction is that it makes impl Trait inconsistent between type aliases and everywhere else (excluding the already inconsistent argument position). With existential type there was a simple rule that could be applied to impl Trait in every position except argument position: it introduces a new anonymous existential type in the current context. This rule works perfectly for return position, type declaration in bindings, type aliases, and could work for type declaration of struct/enum members if there wasn't a chance of confusion with argument-position-impl-trait.

This comment has been minimized.

@varkor

varkor Aug 5, 2018

Author Member

I'm not quite sure I follow your point. This restriction is simply a syntactic one: it is simply intended to sidestep the question of what:

type Foo = (impl Bar, impl Bar);

means for now (because some people expressed unease at this construction in particular).

impl Trait continues to be applicable in exactly the same places as existential type: this rule simply means it can't be used in more complex scenarios than existential type yet.

This comment has been minimized.

@Nemo157

Nemo157 Aug 5, 2018

Contributor

I mean that with existential types it's possible to have a single very simple rule for desugaring impl Trait that covers both:

type Foo = (impl Bar, impl Bar);
let foo: (impl Bar, impl Bar);

but with type Foo = impl Trait; trying to apply the same sort of rule you get to this recursive definition that needs a special case when you use a bare impl Trait in a type alias.

This comment has been minimized.

@varkor

varkor Aug 5, 2018

Author Member

Ah, I see. Yes, you do have to have a single type alias as a base case if you're using the impl Trait type aliases to desugar. In practice, the desugaring of existential type is effectively replaced by the type alias. That is, the existential type design was originally intended to act as a desugaring for return-position and variable-binding (e.g. let) impl Trait. Using type aliases fills that role: but you can't use it to desugar itself. In practice, I don't think this is important, as there's no practical difference between the existential type itself and its alias.

This comment has been minimized.

@alexreg

alexreg Aug 6, 2018

Why can't it be used to desugar, @varkor?

This comment has been minimized.

@varkor

varkor Aug 6, 2018

Author Member

Because if you try to desugar each occurrence of impl Trait you would end up trying to desugar:

type Foo = impl Trait;

into:

type Foo = impl Trait;

so you need to have this as a base case.

@Centril
Copy link
Contributor

Centril left a comment

Some nits

Show resolved Hide resolved text/0000-impl-trait-type-aliases.md Outdated
Show resolved Hide resolved text/0000-impl-trait-type-aliases.md Outdated
Show resolved Hide resolved text/0000-impl-trait-type-aliases.md Outdated
Show resolved Hide resolved text/0000-impl-trait-type-aliases.md Outdated
Show resolved Hide resolved text/0000-impl-trait-type-aliases.md Outdated
Show resolved Hide resolved text/0000-impl-trait-type-aliases.md Outdated
@clarfon

This comment has been minimized.

Copy link
Contributor

clarfon commented Aug 5, 2018

Just to clarify because I'm not 100% certain on how impl Trait works, these will allow where clauses too, right? In other words, type Thing = impl Trait where Self: Clone.

If I recall correctly, impl Trait doesn't allow this, as it'd be ambiguous if you said fn fun() -> impl Trait where Self: Clone because it's unclear whether the Self: Clone applies to the impl Trait or the fn fun().

So technically where clauses would have to be added to make this equivalent to the existential type syntax. It also solves the problem of where clauses for impl Trait, as you can make a type alias if you need them.

I think the RFC should have some discussion of where clauses. I didn't see any.

@Nemo157

This comment has been minimized.

Copy link
Contributor

Nemo157 commented Aug 5, 2018

@clarcharr what's the intended meaning of where clauses on an impl Trait? I can't work out what type Thing = impl Trait where Self: Clone; would do, or why you would use it.

@clarfon

This comment has been minimized.

Copy link
Contributor

clarfon commented Aug 5, 2018

So I just used Self: Clone as an example but a better one might be, for example, impl Iterator where Self::Item: Clone. While in the future this will be doable as impl Iterator<Item: Clone> you need a where clause for this currently.

In general, there are going to be things you can't express without where clauses, and it'd be nice to allow this sort of thing.

@Nemo157

This comment has been minimized.

Copy link
Contributor

Nemo157 commented Aug 5, 2018

Thanks, that clarifies things a lot for me (I think putting a bound directly on Self really threw me since that can be just a + Bound on the trait). And I now remember I actually raised similar points on the RFC that introduced impl Iterator<Item: Clone> (#2289 in case anyone is interested).

In the end I think I decided that that specific case acts identically to impl Iterator<Item = impl Clone>, which I think will cover 90%+ of use cases.

I'm trying to think of useful bounds that can't be written currently, I guess there could be something like impl Iterator where for<'a> &'a Self::Item: SomeTrait maybe?


Relatedly I was playing round with the current implementation and noticed that it allows specifying bounds in the type parameter list but not adding a where clause, that allowed me to come up with an example of a currently compiling -> impl Trait return type that's not representable without where clauses on the type alias (playground)

existential type Bar<T: IntoIterator>: Iterator<Item = <T::Item as IntoIterator>::Item>;

fn bar<T>(a: T) -> Bar<T>
where
    T: IntoIterator,
    T::Item: IntoIterator,
    <T::Item as IntoIterator>::Item: Clone,

so even if Self based where clauses is deemed out of scope since -> impl Trait doesn't require them I hope where clauses for constraining the generic parameters are available.

@clarfon

This comment has been minimized.

Copy link
Contributor

clarfon commented Aug 5, 2018

One thing that I also realised is that this could be accomplished by trait aliases:

trait Trait = Bound where for<'a> &'a Self: Bound
type Thing = impl Trait

So maybe where clauses aren't necessary and might in fact overcomplicate stuff. Still worth mentioning in the RFC, though, as this is something the existential type syntax supported that the new syntax won't.

@varkor

This comment has been minimized.

Copy link
Member Author

varkor commented Aug 5, 2018

Sorry, I'll get the bounds question soon, but in the meantime, I've noticed that this RFC has a major incompatible flaw with the original RFC 2071 and it could potentially mean this syntax suggestion is misinformed.

The current implementation of existential type, which I was using for reference, makes the underlying (inferred) type hidden to the enclosing module. This is consistent with impl Trait and is a motivating factor for the syntax proposed here. However, RFC 2071 explicitly states that the underlying type is transparent within the enclosing module.

As such, existential type and impl Trait are inconsistent with one another. Either:
(a) the feature as implemented right now must be accepted as having the correct semantics
(b) existential type cannot use the impl Trait syntax.

Note that, contrary to some of the original stated motivations, this feature means that existential type cannot be used as a desugaring for impl Trait.

@KodrAus

This comment has been minimized.

Copy link
Contributor

KodrAus commented Aug 5, 2018

However, RFC 2071 explicitly states that the underlying type is visible within the enclosing module.

I did a quick mobile grok of #2071. Is it the reference section you're talking about where the existential type can be used as its resolved type within its declaring module? What's the tradeoff we're making by excluding this transparency from our model here?

@KodrAus

This comment has been minimized.

Copy link
Contributor

KodrAus commented Aug 5, 2018

However, RFC 2071 explicitly states that the underlying type is visible within the enclosing module.

That doesn't seem inconsistent to my mental model actually. I've been thinking of impl Trait in terms of who decides what Trait is?. For return position, the function body decides what Trait is, and that concrete type is visible to the function body. For argument position, the caller decides what Trait is, and that concrete type is visible to the caller. For module position, the module decides what Trait is, and that concrete type is visible to the module.

@varkor

This comment has been minimized.

Copy link
Member Author

varkor commented Aug 5, 2018

Is it the reference section you're talking about where the existential type can be used as its resolved type within its declaring module? What's the tradeoff we're making by excluding this transparency from our model here?

That's right. If we include the transparency, it's inconsistent with impl Trait, which is confusing as now impl Trait may or may not be transparent depending on the location. If we exclude transparency, then it's a change of behaviour from RFC 2071. This might be solvable, but requires some care.

That doesn't seem inconsistent to my mental model actually. I've been thinking of impl Trait in terms of who decides what Trait is?.

Yes, it's quite subtle. It could be consistent with argument-position, return-position and module-position impl Trait. However, it's not consistent with impl Trait in let bindings (another consequence of RFC 2071 that's not yet implemented). The simplest solution is probably altering impl Trait's behaviour in these circumstances to make it transparent.

@KodrAus

This comment has been minimized.

Copy link
Contributor

KodrAus commented Aug 5, 2018

However, it's not consistent with impl Trait in let bindings

Ah right, I'd totally forgotten about let bindings! In that case, I'd expect the expression part of the binding to decide what Trait is, and that concrete type to be visible only within that expression. For example:

let mut x: impl Debug = {
    if a { 1 }
    else { some_fn_returning_i32() }
};

x = 1; // err: expected `impl Debug` got `i32`

@Centril Centril added I-nominated and removed I-nominated labels Jan 17, 2019

@ebkalderon

This comment has been minimized.

Copy link

ebkalderon commented Feb 11, 2019

Is this RFC ready to move forward into the final comment period?

@alexreg

This comment has been minimized.

Copy link

alexreg commented Feb 11, 2019

@ebkalderon I think @Centril intends to FCP-merge this very soon, if I'm not mistaken...

@Centril

This comment has been minimized.

Copy link
Contributor

Centril commented Feb 26, 2019

This RFC has baked and no new arguments have been presented for some time. It's now a good time to give the RFC a final review within the language team and the community at large.

As there's no way to give users access to the feature behind existential type on stable without replacing the placeholder syntax, some new syntax will need to be chosen. I believe that the syntax proposed in this RFC is the one that hitherto meets our requirements best and therefore I propose that we merge the RFC.

@rfcbot merge

However, it needs to be recognized that whatever syntax is chosen as the final one, since no single syntax solely has advantages or drawbacks, there will be trade-offs and value judgements of those will need to be made. Let's consider some of trade-offs, alternatives, and benefits of the proposed syntax.

Advantages of type Foo = impl Bar;

Syntactic minimalism

A clear benefit of the proposed syntax is that it requires no modification to syntax. Rather, impl Trait is already a part of the type expression grammar. Thus, only semantic restrictions will need to be lifted. It is therefore clear that the RFC's solution is the most syntactically minimalistic one that achieves the desired feature-set.

Semantic consistency

The use of impl Trait is semantically consistent with uses in return positions of functions, const and static items, and let bindings. As a result, there's a good chance that some users may learn the feature simply by trying out type Foo = impl Bar; rather than having to learn another syntax. Moreover, the RFC argues that by using impl Trait this way, we can teach the concept cohesively irrespective of where it is used. I'm inclined to agree with this sentiment. This also ameliorates concerns about syntactic substitutability of type aliases.

"Opaque placeholder" is a clear mental model

The RFC proposes a shift in mental model away from existential types and towards type inference. I think this model works well with the existing language. In particular, _ already represents a placeholder which exposes the type identity in a transparent manner. By explaining impl Trait as an opaque placeholder, we can reuse the existing language around placeholders in general and leverage similarities between let x: impl Trait = ..; and let x: _ = ...;. This also opens up possibilities for making _ work wherever impl Trait works and vice versa if we so desire. While we are under no obligation to move in that direction, picking a syntax that is more forward compatible than not is a plus.

A composable mechanism

As impl Trait is a type-expression syntactically, it becomes possible to write things such as:

type First = impl Iterator<Item = impl Debug>;

type Second = Vec<impl Debug>;

Other syntaxes such as abstract type Foo: Bar; do not have such affordances; rather, you must write:

abstract type Item: Debug;
abstract type First: Iterator<Item = Item>;

abstract type Inner: Debug;
type Second = Vec<Inner>;

While the First use can be improved with #2289, the second cannot. Both examples show that the proposed in this RFC compose better with the language we've got for realistic use cases. A third use case is macros. Because the proposed syntax works at the level of type expressions, it is easy to use it as part of macro expansion of associated types in custom derive macros and type aliases in general.

To unlock the composability benefits of this RFC and avoid needless surprises, I also propose that we immediately resolve the remaining unresolved question in the RFC in favour of lifting the restriction. (@varkor, please edit the RFC accordingly once we're in FCP).

Drawbacks

Substitutability

There's one chief oft-cited drawback to the proposed syntax: "referential transparency". In particular, it no longer holds true that the RHS of a type alias may be substituted, given capture avoidance and accounting for type parameters, for its LHS. In particular, as the RFC notes, we cannot move from:

fn foo() -> impl Trait { /* ... */ }
fn bar() -> impl Trait { /* ... */ }

towards:

type SharedImplTrait = impl Trait;

fn foo() -> SharedImplTrait { /* ... */ }
fn bar() -> SharedImplTrait { /* ... */ }

Some users may be confused by this. However, as aforementioned, confusion can also be mitigated by teaching impl Trait in a cohesive manner. To implement the feature itself, it is necessary to check whether defining uses of SharedImplTrait are the same type. That gives us a good opportunity to provide good diagnostics to the confused user. This may further reduce their surprise.

I also the RFC is right to assign the blame to impl Trait itself. To illustrate this, consider the inverse direction:

fn foo() -> u8 { /* ... */ }
fn bar() -> u8 { /* ... */ }

If we move towards foo and bar as first defined, then we may no longer witness that foo and bar have the same underlying type. If a user is confused about this RFC, it stands to reason they would also be confused about moving towards -> impl Trait.

While some formal properties of type aliases are lost, they were already lost in type expressions at large both due to _ and due to impl Trait. In that light, preserving syntactic substitutability in type aliases does not seem like a critical property to retain.

Alternative semantics to type Foo = impl Bar; as proposed in this RFC would be to let it act as a type macro, this would e.g. let you write:

type Alias = impl Trait;

fn foo(arg: Alias) -> Alias { .. }

// this would be equivalent to:

fn foo(arg: impl Trait) -> impl Trait { .. }

This does not seem like a direction we should move in. In particular, it makes it hard to see when quantification actually occurs as Alias may be defined in some far-off location. This use case would be better served by trait aliases. Moreover, this would also make Alias represent different types based on location. That would violate a key property of type aliases, which this RFC preserves, in that they are always the same type after substituting parameters.

"The ratchet"

Another noted concern is that this breaks "the dialectical ratchet" by having -> impl Trait desugar to just more impl Trait. The idea is that beginners can learn an intuitive sugary DWIM syntax. As you learn more, you are introduced to a more explicit syntax with rigorous framework for thinking about problems. This syntax allows you to express more things. Finally, you learn that the sugar was sugar for the explicit version. A good example of where this applies is when going from for loops to the desugaring to loop + match or when going from ? to match.

To me, an important aspect of the ratchet as a pedagogical construct is that the sugary syntax truly be less capable than the explicit and more powerful one. This is true of for, ?, lifetime elision, and arg: impl Trait not because we intentionally wanted to limit the power of these constructs with no benefits to ergonomics and usability. Rather, power and usability are at odds when it comes to things like ?, arg: impl Trait, etc. In the latter case, the primary power you give up is having a name for the type to reuse elsewhere.

In the case of type Foo = impl Bar;, it does not seem like there's noteworthy power that the simple syntax gives up. In fact, as shown before, the proposed syntax seems to compose better than other suggestions. As the simple syntax covers advanced uses, I don't think there's much point in forcing users to abandon their intuitive notion when more power is needed. To me, the ratchet is not an end in itself and its justification should lie in the extent to which it helps facilitate lazy-learning while enabling complex use cases when needed. The proposed syntax does both and requires users to learn less overall without diminished expressive power.

Concerns re. type Alias = Trait;

One noted concern is that should we let impl Trait be written with bare Trait, then one one could write type Alias = Trait;. In turn, this may be confusingly similar to trait objects. Such a change would work against the internet's collective memory around how Rust works. I think precisely for those reasons, the probability of introducing Trait as impl Trait is low. Moreover, it does not seem warranted to mix the type and bounds categories in this, confusing, way.

Alternatives

Finally, let's consider some of the notable alternatives to this RFC. All of these syntaxes have in common that they are another syntax that must be learned as opposed to the syntax in this RFC.

exists<T> { ... }

This syntax explicitly delineates the scope for existential types quantified in the binder. The quantified type T may then be bound to a name outside, with e.g.:

exists<T: Debug> {
    // Here go all the items that depend on the opaque type `T`:
    type MyDebug = T;
}

The syntax -> impl Trait is then desugared by creating an exists<T: Debug> { .. } scope around the function.

The main benefit of this syntax is that the scope is clear from the structure. Moreover, it provides a highly explicit desugaring target for the ratchet. However, the syntax also suffers from right-ward drift and should be especially hard for macros to deal with. As syntax changes goes, it is also quite an intrusive change to the language requiring users to make large rather than small changes to a piece of code. Finally, this syntax locks us into the language of existentials which is a mental model we may not want to expose to users.

keyword? type Foo: Bar;

These proposed syntaxes are of the general form:

<keyword> = ϵ | "abstract" | "opaque" | "anon" (ymous) | "exists" ;
<keyword> type Foo: Trait;
<keyword> type Foo: Trait = ConcreteTy; // optional extension

The RFC notes that:

The other alternatives commonly given are:

  • type Foo: Bar;, which suffers from complete and confusing inconsistency with associated types. Although on the surface, they can appear similar to existential types, by virtue of being a declaration that "some type exists [that will be provided]", they are more closely related to type parameters (which also declare that "some type exists that will be provided"), though type parameters with Haskell-style functional dependencies. This is sure to lead to confusions as users wonder why two features with identical syntax turn out to behave so differently.

Indeed, a syntax type Foo: Bar; would immediately collide with #2532 since the ability to specify trait Foo { type Bar = impl Baz; } would need to be replaced with some syntax. However, if that syntax is type Bar: Baz;, then that already has a meaning in the definitions of trait items. Thus, we must either reject type Foo: Bar; or give up expressive power.

If instead we add a prefix such as opaque, then we can say trait Foo { opaque type Bar: Baz; }. However, this suggests that Bar is some special form of associated type rather than the mere act of providing a default. This again shows how type Bar = impl Baz; is composable and orthogonal syntax while <keyword> type Bar: Baz; is not. With the former, you simply take associated type defaults, e.g. trait Foo { type Bar = u8; }, use impl Baz as the default type, and now you have an opaque type as the default scoped at the trait item just like type Bar = impl Baz; would be scoped at an impl item.

The forms <keyword> type .. do not compose in the way that type Foo = Vec<impl Bar>; does nor are they as friendly for macros.

As a further note about abstype or abstract type, I believe that the notion of a type being abstract does not convey much. One could argue that it is the type identity that has been abstracted and cannot be inspected from the outside. A more direct word for that notion is "opaque".

The main thing that these syntaxes have going for them is that they aren't expected to have any sort of substitutability property. However, the drawbacks seem substantial wherefore I believe that we should reject these forms.

@rfcbot

This comment has been minimized.

Copy link

rfcbot commented Feb 26, 2019

Team member @Centril has proposed to merge this. The next step is review by the rest of the tagged team members:

Concerns:

Once a majority of reviewers approve (and at most 2 approvals are outstanding), this will enter its final comment period. If you spot a major issue that hasn't been raised at any point in this process, please speak up!

See this document for info about what commands tagged team members can give me.

@scottmcm

This comment has been minimized.

Copy link
Member

scottmcm commented Feb 26, 2019

I think the disadvantage that the summary is missing is that the "we're just piggy-backing on argument/return intuition" reasoning for having the same syntax for different things is not true in type aliases, and especially not if the same thing becomes legal in trait aliases (which I'd expect it to be).

Are all of these existential?

type Foo = Arc<impl Trait>;
trait Bar = Add<impl Trait>;
trait Qux = Iterator<Item = impl Trait>;

I honestly don't know, because, for example, I remember the last of those being proposed as meaning Iterator where Self::Item: Trait;

Or does this only work at top level?

@alexreg

This comment has been minimized.

Copy link

alexreg commented Feb 26, 2019

@scottmcm I would expect the following desugarings:

type Foo = Arc<impl Trait>;

-->

type Foo<T: Trait> = Arc<T>;

(analogous to APIT in fns)

trait Bar = Add<impl Trait>;

-->

trait Bar<T: Trait> = Add<T>;

(again, analogous to APIT in fns)

trait Qux = Iterator<Item = impl Trait>;

-->

trait Qux = Iterator<Item = _0>;
existential type _0: Trait;

(analogous to RPIT in fns)

The third case also has a relationship to associated type bounds. For example, trait Qux = Iterator<Item: impl Trait>; would desugar to trait Qux = Iterator<Item = impl Trait>;, which in turn would desugar as per the above example. (Note that the case of associated type bounds within trait aliases isn't explicitly covered by that RFC, but there's no reason we shouldn't support it too.)

@dhardy

This comment has been minimized.

Copy link
Contributor

dhardy commented Feb 26, 2019

@alexreg your interpretation implies that one should be able to write:

type Foo = Arc<impl Trait>;
type FooX = Foo<X>;    // where X: Trait

So, suddenly the definition of type Foo implies that this is generic over some X? I find this counter-intuitive and would assume the existential interpretation personally.

For what it's worth, I think the adoption of APIT was misguided; however I recognise that is hard to change now.

@withoutboats

This comment has been minimized.

Copy link
Contributor

withoutboats commented Feb 26, 2019

@rfcbot concern thinking

My thinking on this has shifted to some degree recently but I haven't had time to think about it in a sustained and serious manner. I don't have the time to think about it just now, registering a concern until I do.

@Nemo157

This comment has been minimized.

Copy link
Contributor

Nemo157 commented Feb 26, 2019

@dhardy if it is fully consistent with APIT then

type Foo = Arc<impl Trait>;
type FooX = Foo<X>;

should give an error like

cannot provide explicit type parameters when `impl Trait` is used in argument position.

and instead Foo should only be able to be used in cases where type inference can determine what the inner type is (whether that includes using it in another type alias and letting that alias be determined via inference as well, I'm not sure).

@dhardy

This comment has been minimized.

Copy link
Contributor

dhardy commented Feb 26, 2019

Centril made a very nice argument about why this may be the best candidate syntax for existential types, and now we have confusion over whether the syntax is used for existentials (as in RPIT) or sugar for a hidden type parameter (as in APIT).

Thus we have the following options:

  • restrict impl Trait to function definitions, and find another syntax for existential type aliases (if any)
  • use impl Trait for existentials in type aliases and RPIT, for hidden type parameters in APIT
  • use impl Trait for existentials in type aliases and RPIT, deprecate APIT (thus impl Trait is only for existentials)
  • allow impl Trait in type aliases in some other way (I am strongly against this: it adds confusing sugar for existing little-used functionality (parametrised type aliases) without allowing existential types)
@Nemo157

This comment has been minimized.

Copy link
Contributor

Nemo157 commented Feb 26, 2019

One reason that the RPIT/APIT distinction for type aliases doesn't make sense is that the impl Trait's position inside an RPIT/APIT type doesn't affect its meaning, it's only based on the overall position in the function signature. In this playground you can see that both generic type parameters and associated type parameters in an RPIT type are treated as existential types, while both generic type parameters and associated type parameters in an APIT type are treated as hidden generics.

To extend one of @scottmcm's examples further, consider:

type Foo = Arc<impl Iterator<Item = impl Debug>>;

in this case if the type inside Arc is considered as an APIT type, and so a hidden generic, then so must the inner Item type, but if you split this out to multiple aliases

type FooIter = impl Iterator<Item = impl Debug>;
type Foo = Arc<FooIter>;

now both of the impl Trait types must be considered to be existential types and Foo is no longer generic.

Overall it seems like a type/trait alias is an output type of the module and therefore should always be treated the same as an RPIT type, meaning impl Trait is always an existential type inside it. If Rust supported parameterised modules then there could be some sort of syntax for an input type to the module which would use impl Trait as a hidden generic, but that doesn't currently exist.

@alexreg

This comment has been minimized.

Copy link

alexreg commented Feb 26, 2019

@dhardy

your interpretation implies that one should be able to write:

type Foo = Arc<impl Trait>;
type FooX = Foo<X>;    // where X: Trait

So, suddenly the definition of type Foo implies that this is generic over some X? I find this counter-intuitive and would assume the existential interpretation personally.

Not really. In type Foo = Arc<impl Trait>; there is no type parameter used or declared, in type FooX = Foo<X>; there is one used but not declared! The latter doesn't make sense. They're quite distinct scenarios.

For what it's worth, I think the adoption of APIT was misguided; however I recognise that is hard to change now.

This has been expressed by a bunch of people, but it's far too late... there are pros and cons either way.

@alexreg

This comment has been minimized.

Copy link

alexreg commented Feb 26, 2019

@Nemo157 Your desugaring is wrong, I believe.

type Foo = Arc<impl Iterator<Item = impl Debug>>;

-->

type Foo<T: Iterator<Item = _0>> = Arc<T>;
existential type _0: Debug;

That's certainly what I would expect.

@Nemo157

This comment has been minimized.

Copy link
Contributor

Nemo157 commented Feb 26, 2019

@alexreg that’s not a desugaring, that’s a manual splitting of the type alias, maybe because the author has decided that being able to name the inner type individually would be useful. This would work under the “always existential” rule, but wouldn’t with the type of the generic varying like you mention.

@alexreg

This comment has been minimized.

Copy link

alexreg commented Feb 26, 2019

@Nemo157 Oh, I see what you mean. Yes... we need to think about how best to teach that, since that intuitive splitting does of course change semantics.

@rpjohnst

This comment has been minimized.

Copy link

rpjohnst commented Feb 26, 2019

@dhardy

Centril made a very nice argument about why this may be the best candidate syntax for existential types, and now we have confusion over whether the syntax is used for existentials (as in RPIT) or sugar for a hidden type parameter (as in APIT).

This is a misunderstanding of the term "existential" and the whole reason we are moving away from that description.

APIT and hidden type parameters are existential- the types ∀a.fn(a) and fn(∃a.a) are isomorphic. Both accept any value the caller decides to throw at them. It is RPIT that is not really an existential, because the callee cannot produce any value it likes, but is limited to a single actual type.

The proposed interpretation is instead "impl means infer an opaque type." APIT and RPIT are still a bit different here, but much closer- their behavior is analogous to ML-like type inference for functions. And if you look at the Fn traits, you can see a concrete reason for the remaining conceptual difference: function parameters correspond to trait parameters, while the return type corresponds to an associated type.

@dhardy

This comment has been minimized.

Copy link
Contributor

dhardy commented Feb 26, 2019

@rpjohnst I agree that "existential" is not really the correct terminology (if you look up 20 or so posts, you'll see I suggested some alternatives). It does appear to be what everyone is using, but you are right: calling these "opaque types" is more accurate.

There is however a very important difference. E.g. is the following legal? Is S well defined as a type, or is it generic? Is the current scope parametrised with a hidden type such that S is only well defined locally?

type T = impl Trait;
struct S {
    value: T
}
// ... some more code to constrain the type of T sufficiently

What I would personally like to see is S being a well-defined type globally; even one that can be exported as part of the public API.

@alexreg

This comment has been minimized.

Copy link

alexreg commented Feb 26, 2019

@dhardy I would expect that in your example S would not be implicitly parametrised, but rather T would be an inferred opaque type, but concrete. We may want different semantics for something like struct S { value: impl Trait } though, where S would actually be parametrised. (Probably not, but I just thought I'd leave it open, since @Centril may have ideas about this.)

@Nemo157

This comment has been minimized.

Copy link
Contributor

Nemo157 commented Feb 26, 2019

I just realised the issue I have with @alexreg's desugaring above that was hiding the use of APIT. Specifically, concrete types should be transparent to the APIT/RPIT distinction (as evidenced by my playground above):

type Foo = Arc<impl Trait>;

should be the equivalent of what is written today as

type Foo = Arc<_0>;
existential type _0: Trait;

This follows from treating type Foo as the same base context as RPIT, you can have -> Arc<impl Trait> which means that the function chooses the type inside the Arc.


The interesting question is what should happen when you have nested impl Trait inside impl Trait, something like

type Bar = impl std::ops::Add<impl Into<i32>, Output = impl std::fmt::Display>;

fn bar() -> Bar { ... }

this could take the form that @alexreg expands above where input type parameters to traits get flipped, but that seems to require introducing a non-local hidden generic type parameter onto functions (it would even be possible to introduce this hidden parameter onto a function outside the current module if it returns a value it got from calling a function inside the module)

type Bar<_0: Into<i32>> = _1<_0>;
existential type _1<_2: Into<i32>>: std::ops::Add<_2, Output = _3>;
existential type _3: std::fmt::Display;

fn bar<_4: Into<i32>>() -> Bar<_4> { ... }
EDIT: Unless higher ranked type-generic bounds were a thing, maybe?
type Bar = _1;
existential type _1: for<_2: Into<i32>> std::ops::Add<_2, Output = _3>;
existential type _3: std::fmt::Display;

fn bar() -> Bar { ... }

Looking at what happens if you try and write the equivalent today, as an RPIT:

fn bar() -> impl std::ops::Add<impl Into<i32>, Output = impl std::fmt::Display> { ... }

gives `impl Trait` not allowed outside of function and inherent method return types on the impl Into<i32> (but is ok with the impl std::fmt::Display as the output, so that message is technically wrong), while

existential type Bar: std::ops::Add<impl Into<i32>, Output = impl std::fmt::Display>;

gives the same error on both uses of impl Trait.

@rpjohnst

This comment has been minimized.

Copy link

rpjohnst commented Feb 26, 2019

@dhardy

What I would personally like to see is S being a well-defined type globally; even one that can be exported as part of the public API.

This matches the proposal. That use of impl Trait is not in a function argument, so it doesn't correspond to a trait parameter from the Fn traits, or a location where ML-like inference would find polymorphism, so it does exactly what you want.

@Centril

This comment has been minimized.

Copy link
Contributor

Centril commented Feb 26, 2019

@alexreg

The third case also has a relationship to associated type bounds. For example, trait Qux = Iterator<Item: impl Trait>; would desugar to trait Qux = Iterator<Item = impl Trait>;, which in turn would desugar as per the above example. (Note that the case of associated type bounds within trait aliases isn't explicitly covered by that RFC, but there's no reason we shouldn't support it too.)

If we map out where the RFC would naturally lead, it would desugar to trait Qux = Iterator where <Self as Iterator>::Item: Trait;. The RFC specifies it in terms of where-clauses; that this has equal semantics to impl Trait in some circumstances is incidental.


@Nemo157 Your desugaring is wrong, I believe.

type Foo = Arc<impl Iterator<Item = impl Debug>>;

-->

type Foo<T: Iterator<Item = _0>> = Arc<T>;
existential type _0: Debug;

That's certainly what I would expect.

Under this RFC, type Foo = Arc<impl Iterator<Item = impl Debug>>; should be equivalent to:

existential type _0: Debug;
existential type _1: Iterator<Item = _0>;
type Foo = Arc<_1>;

@varkor, can you please clarify this in the reference (and in general how nested impl traits work where they are allowed (but notably not impl Foo<impl Bar> since it isn't allowed anywhere today...))?


@dhardy

There is however a very important difference. E.g. is the following legal? Is S well defined as a type, or is it generic? Is the current scope parametrised with a hidden type such that S is only well defined locally?

type T = impl Trait;
struct S {
    value: T
}
// ... some more code to constrain the type of T sufficiently

From the perspective of type inference with opacity, let's desugar your example (with defining use fn foo):

type T = forget(?0, Trait);
struct S { value: T }

pub fn foo() -> T { 42u8 }

Here, ?0 denotes a unification variable. Meanwhile, forget(X, Y) takes a type X and forgets the type identity ("details" re. scoping omitted...) and leaves Y as the interface you can assume about X. Specifically, we take ?0 and make it opaque such that only Trait can be assumed to hold about ?0.

Type aliases are aliases and therefore every monomorphic use of them should refer to the same type. Combined with ?0 being an inference variable, it is quite natural that we equate ?0 to a specific type (given by foo, so u8) and then forget exactly what type it was.


@alexreg

We may want different semantics for something like struct S { value: impl Trait } though, where S would actually be parametrised. (Probably not, but I just thought I'd leave it open, since @Centril may have ideas about this.)

Probably not :) If we have value: impl Trait, I would assign the existential type meaning to the type of value.


@Nemo157

The interesting question is what should happen when you have nested impl Trait inside impl Trait, something like

type Bar = impl std::ops::Add<impl Into<i32>, Output = impl std::fmt::Display>;

fn bar() -> Bar { ... }

This is an error for the same reason that -> impl Foo<impl Bar> is an error today. See rust-lang/rust#57979 (comment). In particular, it could mean: -> impl for<T: Bar> Foo<T>.

@pnkfelix

This comment has been minimized.

Copy link
Member

pnkfelix commented Mar 13, 2019

@Centril wrote

To unlock the composability benefits of this RFC and avoid needless surprises, I also propose that we immediately resolve the remaining unresolved question in the RFC in favour of lifting the restriction. (@varkor, please edit the RFC accordingly once we're in FCP).

One great property of this RFC as written is that it is truly just resolving a syntactic question, without attempting to add any new expressiveness on top of the current implementation.

Correct me if I misunderstand what @Centril wrote, but it seems like the call for a final comment period is suggesting an implicit amendment to the RFC that breaks that property.

  • In other words, I don't know if the FCP summary comment is trying to say that we are checking off approval on a document that has this has-yet-unwritten change in place.

I understand the goal of trying to strive to maximize uniformity and expressive power.

But I worry about an overall issue in the RFC process, where an implicit change is proposed to the RFC via a single sentence in 2,000 word summary comment (a comment that is otherwise just condensing the dialogue up to this point, and not attempting to introduce new ideas).

  • Its a bad precedent to set
  • It increases the chance of team members having different mental models of what change is being proposed.

Having said that, I don't care enough about the potential for process-breakdown to actually block the FCP on this procedural matter. I just wanted to voice my opinion on the matter.

@pnkfelix

This comment has been minimized.

Copy link
Member

pnkfelix commented Mar 13, 2019

@rfcbot reviewed

@varkor

This comment has been minimized.

Copy link
Member Author

varkor commented Mar 13, 2019

One great property of this RFC as written is that it is truly just resolving a syntactic question, without attempting to add any new expressiveness on top of the current implementation.

To add to this point, I think there is no disadvantage to leaving the point in question as an open question. One straightforward possibility is to add an additional feature gate that permits this extended use of impl Trait to allow experimentation without extending the scope of this RFC past a syntactic change.

@Centril

This comment has been minimized.

Copy link
Contributor

Centril commented Mar 13, 2019

Correct me if I misunderstand what @Centril wrote, but it seems like the call for a final comment period is suggesting an implicit amendment to the RFC that breaks that property.

It suggests resolving the existing unresolved question. To make this clearer and to ensure that the document is revised, I'll add concerns to that end:

@rfcbot concern resolve-unresolved-question-in-favor-of-allowing-it
@rfcbot concern clarify-that-multiple-impl-traits-have-usual-current-semantics

One great property of this RFC as written is that it is truly just resolving a syntactic question, without attempting to add any new expressiveness on top of the current implementation.

I think this remains the case. E.g. consider:

#![feature(existential_type)]

existential type _0: core::fmt::Debug;
existential type _1: core::fmt::Debug;
type _2 = (_0, _1);
fn foo() -> _2 { (1, 2) }

existential type _3: core::fmt::Debug;
existential type _4: Iterator<Item = _3>;
fn bar() -> _4 { 0..1 }

fn main() {}

This compiles fine on nightly and would be rewritten as:

type _2 = (impl core::fmt::Debug, impl core::fmt::Debug);
fn foo() -> _2 { (1, 2) }

type _4 = impl Iterator<Item = impl core::fmt::Debug>;
fn bar() -> _4 { 0..1 }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.