Protocol<.AssocType == T> shorthand for combined protocol and associated type constraints without naming the constrained type

(Joe Groff) #7

It'd be interesting to me to hear whether people find this notation in Rust to be confusing. To me, same-type-constraining an associated type of a protocol seems perfectly isomorphic to binding the argument of a generic struct. In a language without side effects, a protocol would in effect be a lazification of a struct:

// A strict point
struct Point<T: FloatingPoint> {
  var x, y: T
}

// A conforming type can be lazily evaluated as a point
protocol Point {
  associatedtype T: FloatingPoint
  var x, y: T
}

In either case, any specific value can only be one specific Point<T> or Point<.T == T>, and constraining the type parameter or associated type has the same effect on the available interface on the type.

3 Likes
(Adrian Zubarev) #8

@Joe_Groff a few questions.

Protocol is a placeholder for a composed types right? I do miss in Swift something like generalized type constraints which @anandabits pitched a few times already. That would allow more complex type constraints and things like upgrading a generic type locally. if let some = t as? T & SomeProtocol. What I mean here is that I think Protocol is a little confusing as I do expect more types to appear at that composition position, not only protocols or classes.

Is your pitched syntax flexible enough that it can be shared? Here I would like to create a type alias that can be used as an existential. Then if required I can just add a keyword to it so it becomes an opaque type.

typealias IntCollection = Collection<.Element == Int>
opaque typealias _IntCollection: IntCollection = [Int]

To be honest I would prefer a syntax that looks more like Collection where Element == Int instead.

(Mox) #9

I might be mistaken but Element the feels ambiguous without anything rooting it to Collection.

So alternatives would be named:
C: Collection where C.Element == Int
Or anonymous:
Collection where .Element == Int

Personally I prefer named, but since this proposal is about anonymous, I’d choose the dot syntax. It feels more logical. I understand if <> are necessary for avoiding ambiguities, otherwise I’d also prefer ”where”.

Underscore notation in this context is confusing. Underscore is currently used in Swift as a placeholder for property (i.e instance of type), yet here it’s referring to a type, and you’re expected to make a connection between Collection and _. It’s not similar at all to me.

3 Likes
(Brent Royal-Gordon) #10

This touches on my main concern with SE-0244, which is that the some P syntax may not extend well once we add where clauses, multiple opaque returns, or other plausible features.

I'd like to suggest an alternative solution: Make some always* be an anonymous shorthand for some syntax involving named generic parameters. For instance (strawman syntax abounds here):

Generic parameter
Anonymous func f1(_: some Collection)
Named func f1<C: Collection>(_: C)
Where clause func f1<C>(_: C) where C: Collection
Opaque result type
Anonymous func f2() -> some Collection
Named func f2<result C: Collection>() -> C
Where clause func f2<result C>() -> C where C: Collection
Opaque typealias
Anonymous typealias OpaqueCollection: some Collection = ConcreteCollection
Named typealias OpaqueCollection<result C: Collection> = ConcreteCollection
Where clause typealias OpaqueCollection<result C> = ConcreteCollection where C: Collection
Generalized existential
Anonymous Any<some Collection>
Named Any<C: Collection>
Where clause Any<C where C: Collection>

If you used a named form, you could reuse the same type in multiple positions, constrain it, etc. (Or at least you could write those things—the compiler might not support some of them.) If you used an anonymous form, you wouldn't be able to express those things, but you could always transform to a named form. We might even be able to provide a local refactoring to do it for you.

* I'm not necessarily suggesting that SE-0244 needs to be rejected because it doesn't have a named form yet, but if we went in this direction, we'd want to add one in the next release.

1 Like
SE-0244: Opaque Result Types
SE-0244: Opaque Result Types (reopened)
SE-0244: Opaque Result Types
#11

Is another alternative here that you could be forced to always give a name to the type? You say

but it's not immediately obvious to me if this is true. If you're forced to somehow name your opaque type then all the usual syntax can just apply directly (e.g. ignoring specific syntax, something like func foo<T>() -> some O: Protocol where O.A == T, O.B: P or func foo<T>() -> some O where O: Protocol, O.A == T, O.B: P). Does this not work in some context? Is it too onerous to have to give it a name?

(Joe Groff) #12

That's certainly part of my motivation here. A design that always attaches a name to the opaque/erased thing could work too, but I think there's some benefit to a notation that reduces the amount of names a user has to think about. As they say, naming things is one of the hardest problems in computer science, and names impose a cognitive overhead on the reader to keep track of what the names represent. Notation that reduces names can reduce cognitive load; this is why we like programming languages with expression syntax instead of writing assembly language or LLVM IR, and why it's simpler to write foo(_: P) than foo<T: P>(_: T).

It seems to me that, with the proposed syntax, you should be able to express almost anything you would be able to express with where clauses, with a few open questions. As @anandabits noted, there are a few possible answers for where the <> ought to go in a protocol composition when relating associated types from different protocols. Also, if we did introduce multiple opaque types in a declaration, you would need to introduce extra generic parameters to be able to relate associated types across the opaque types, e.g. to say that two opaque arguments and their return type all return collections with the same Element, you'd write:

func concatenate<Element>(a: some Collection<.Element == Element>,
                          b: some Collection<.Element == Element>)
  -> some Collection<.Element == Element>

instead of directly same-type-constraining the three .Element associated types.

3 Likes
(David Hart) #13

I really like this syntax. It resolves many issues we'll have in future proposals for opaque types and for future generalised existentials.

The only slight issue I have is that if its used too heavily in generic constraints, it tends to make declarations less readable: I like using the <> syntax to name generic types and relegate constraints to the where. The shorthand does tend to make the <> part heavier.

1 Like
(Adrian Zubarev) #14

Honestly I'm not completely sure I like it as it seems that it will block generic protocols to be ever introduced. At least that is my impression if you compare the following two protocols:

protocol P {
  associatedtype T
  ...
}

protocol Q<T> { ... }

func foo(_ p: P<.T == Int>) { ... }
func bar(_ q: Q<Int>) { ... }

On the other hand if we can unambiguously use the same syntax for both features, then I think I'll be totally fine with it.

protocol Z<T> {
  associatedtype R
  ...
}

func baz(_ z: Z<Int, .R == Int>) { ... }

@regexident do you know if the latter is possible in Rust?

1 Like
(Joe Groff) #15

In Rust, you can combine them. If we added generic parameters to Swift protocols, we could do the same. The leading . helps disambiguate these embedded associated type constraints from generic parameters.

5 Likes
(Adrian Zubarev) #16

Well then I'm sold on the syntax as this would be definitely an advantage over the previous iteration of the pitched syntax forms. ;)

1 Like
(Joe Groff) #17

One next step I'd like to see after this is to allow opaque type notation for arguments as well as results, which should also help reduce the weight of generic constraints by allowing many of them to get pushed down to the arguments they constrain.

3 Likes
(David Hart) #18

Opaque types for arguments? How does that work? Clients can't promise to always pass the same concrete type.

(Joe Groff) #19

The analog of an opaque type to an argument would be a unique generic parameter. The type is "opaque" to the callee. You could write foo<T: P>(x: T) as foo(x: some P).

2 Likes
(Rob Mayoff) #20

(emphasis added)

Did you mean “a combined constraint that T implements Trait and that…”?

(Joe Groff) #21

Fixed, thanks!

(Xiaodi Wu) #22

Mmm, seems like we'd be duplicating features then, or at least syntax?

Based on your analogy, I occurs to me that we could try to reuse notations instead of some/opaque:

f<T: 
P /* opaque to callee */>(x: T) -> <U: Q /* opaque to caller */> U

Associated type constraints would fall naturally out of that notation:

f<T: FixedWidthInteger>(x: T) -> <U: Collection> U where U.Element == T
(Joe Groff) #23

You could, and that's effectively literally what a function with an opaque result does. But much like traditional generic notation for arguments, I think there's benefit to notation that reduces the number of things you need to name.

(Brent Royal-Gordon) #24

Are we concerned that people might assume that foo() -> some P works the same way as foo(_: some P)—that is, that it allows the caller to choose the type of P—and that this would therefore be confusing?

(Xiaodi Wu) #25

I agree that naming is hard, but here what we're working with are effectively "internal" names (as by analogy to the distinction between parameter labels and argument names).

What I see in the review thread is pervasive worry that by being unnameable there's going to be difficulty with more complex constraints, or in reusing opaque types for multiple return values. And here we are having a whole thread about inventing new syntax to avoid it.

IMO, there is simplicity in allowing users to name these things, and if it's not a meaningful thing to name, we have plenty of precedent to use T, U, and V.

1 Like
(Anthony Latsis) #26

Rust and Swift are both powerful and expressive languages, but what really makes Swift stand out for me is how remarkably well is it syntactically designed to resemble human language; the tenet to prefer clarity over brevity while still capable of being relatively succinct in most cases. I hold great respect for the beautiful minds that envisioned and shaped the language to its current state. Hitherto, these design patterns have stood firm and won many hearts. @Joe_Groff, the generic dialect you suggest may happen to be a great longstanding approach for, obviously, Rust, and other more "cryptic" languages like Haskell, but for Swift it feels more like a barrier to future possibilities. I believe where clauses can handle it by a wide margin and continue to boost the expressiveness of the language, allowing generic entities to freely interact within a single list that can always be formatted and arranged to make the best sense.

Support for naming type variables is what enables us to list requirements, disambiguate between them and also use the type variable itself in the where clause.

func foo() opaque<C: Collection> -> C where C.Element == C 

The compiler does not support conditional conformance requirements yet, but that might happen in the future. A where clause copes perfectly with this task. Splitting the wordiness into smaller chunks means, well... a lot of diagnostics to ensure they remain sufficiently "self-contained", especially if we end up not trading flexibility for type anonymity.

func foo<T: Collection>() opaque<C: Collection> -> C
  where T: Equatable when C.Element: Equatable
1 Like