SE-0346: Lightweight same-type requirements for primary associated types

I think a more general way of specifying opaque result type requirements is something we’ll probably want eventually. My preferred solution is named opaque result types, because then we have a symmetry:

  • named generic parameters with a where clause vs ‘some P’ or ‘some P<…>’ as the type of a parameter
  • named opaque result types with a where clause vs ‘some P’ or ‘some P<…>’ as the result type

Note that specifying requirements with a “mini where clause” in angle brackets is still not fully general; for example you can’t return two Sequence types both having the same (opaque) element type.

2 Likes

Right, yes, good point: Dictionary, for example, would be sensitive to declaration order. That is the last nail in the coffin for primary type inference, I think.


+1


I find this all compelling on first blush.

It resolves many of my misgivings: it makes it clear that primary associated types are still associated types, reduces the surface area of this new “primary” concept (with Mox’s addition), and makes the “best guesses by looking at the syntax” mental model substantially less likely to be wrong. Documentation is also a compelling point.

Edit: Deleted a concern I raised about @Moximillian’s additional suggestion, forgetting that this is already legal Swift:

protocol Foo where Bar: Equatable { associatedtype Bar }
2 Likes

I’m not sure I follow why this would be prohibited. A protocol where clause can reference associated types declared within the protocol itself. There’s no semantic distinction between where clauses on the protocol and associated types inside, it’s just a matter of taste.

Yeah, did a facepalm and edited my post as you were typing — but you were too fast for me!

It’s a reasonable assumption to make, just not how it happened to be implemented ;-)

1 Like

Generally positive, with a few niggles.

There is a definite 'usability cliff' between using regular types and then moving to generics, where you start having to look in multiple places to see the relationships between type parameters and their constraints to work out what is going on. This definitely helps with that, and (as discussed in the Pitch) is an important step that will unlock other further improvements.

I think there's a nice symmetry between the use of <> in concrete types that adopt protocols and associated types that will become the primary associated types in these protocols. I do have slight concerns that it will muddy the waters a little on the meaning of this syntax (when understanding the difference between type parameters and associated types is already tricky enough), but not overly so.

The potential for removing from the public API surface of the standard (and other) libraries of types that are only present as a concrete return type (e.g. AsyncMapSequence, LazyMapCollection, etc would also be very helpful for reducing the cognitive load of using the corresponding methods.

There are a couple of things that makes me wonder how much of this simplification will be possible though:

  1. Conditional conformances. Some of the concrete return types (e.g. AsyncMapSequence, LazyMapCollection have conditional conformances. Will this be possible with opaque return types?

  2. @rethrows protocols. AsyncSequence and AsyncIteractorProtocol are both marked as @rethrows. So, using this from the proposal:

func readSyntaxHighlightedLines(_ file: String) -> some AsyncSequence<[Token]> {
  ...
}

How would the caller know whether or not they need to use try when using the returned value? (ie. whether they need use for try await tokens in readSyntaxHighlightedLines("filename") or for await tokens in readSyntaxHighlightedLines("filename")?). Looks like this was brought up in the pitch, but I didn't see a response.

This is also related to being able to simplify the API of AsyncSequence.map, etc. Currently, if the closure passed to map is throwing, you'll get a type that provides a throwing conformance to AsyncSequence back as the return value. If it isn't, you'll get a type that provides a non-throwing conformance to AsyncSequence. Will there be some way to abstract this in an opaque return type?

let lines = Just("line").values
let transformed = lines.map { line in line.count }
for await line in transformed {
  ...
}

let transformedThrowing = lines.map { line in throw SomeError() }
for try await line in transformedThrowing {
  ...
}

I participated in the pitch thread and thought about how this would apply to existing API designs in the standard library.

3 Likes

These are good points!

I missed that some/any will be usable in type position! I love how elegantly that solves the problem.

I support the sentiment for requiring all type parameters, that others have expressed in the thread. This, or perhaps supporting defaults by some other means.


For the types provided in the standard library, there's no need for Collection<Index, Element>, I'll give you that. However, I think there could well valid reason to include Index on a Collection-like protocol. I could foresee a framework providing families of concrete collection types, such as contiguous or linked lists optimised for different use-cases, and users writing code to be generic over them, sometimes requiring Index == Int perhaps, but other times not. But to counter my own point, perhaps a new protocol would be needed for this use-case, to more finely define Array-like behaviour as a requirement.

I'm now cautiously supportive of your stance that Index isn't needed, and that things wont spiral out of control with everything becoming a primary type; the latter especially if we require all primary types be written out, no defaults.


One last thought. As a user, if I wished to use Collection<Index, Element>, but only Collection<Element> were provided, could I use a typealias for this?

Roughly:

typealias Collection<Index, Element> = Collection where Self.Element == Element, Self.Index == Index

func foo() -> some Collection<Int, String>

I forget whether this kind of typealias is meant to be supported, now or in the future, but if so, doesn't it rather fulfil the need this feature seeks to meet without any new syntax?

(I ask, expecting to be met with sound reasoning once more. :slightly_smiling_face:)

That won't work exactly as written today because there's no Self in scope, but I understand exactly what you're asking for.

This slightly uglier form works today and is essentially equivalent to the 'constraint alias' feature that you and others have been imagining:

typealias CollectionWithIndex<T, Index, Element> = Any where T : Collection, T.Index == Index, T.Element == Element

func foo<T : CollectionWithIndex<T, Int, String>>(_: T) {}

A better form would essentially be some kind of sugar for the above where T won't appear in the generic parameter list of the alias, but I'm not 100% sure what the surface syntax would be like.

3 Likes
  • What is your evaluation of the proposal?

Strong +1, it feels like a natural extension of the language from my perspective.

I appreciate all the thought put into the criticisms of this proposal, particularly:

  • the Sequence<.Element == Int> alternative
  • the "generic protocols" discussion

I feel the authors have done an excellent job explaining their choices in these areas. I find the resulting proposal very compelling.

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes, it has the potential to simplify a lot of declarations, especially in functions. I found the "equivalent to" examples in the proposal particularly compelling.
Having written my fair share of generic framework code for a large enterprise app, I can see myself preferring this to the existing where clauses.

  • Does this proposal fit well with the feel and direction of Swift?

Yes, I think so. But this is very subjective.

At first blush, it felt like a departure from Swift as I know it. I'm very familiar with using generics and reading their signatures, so at first this was admittedly difficult for me to interpret.

However, reading the proposal with fresh eyes, this feels like a very natural way to introduce "simple generic constraints" to beginners. I found the use cases very compelling.

I have taught Swift's protocols to other programmers experienced in languages like JavaScript and Ruby. I've noticed associated types are a frequent sticking point. I think the generics-like syntax will be more approachable.

That said, as evidenced by some of the reactions in the pitch thread, experienced programmers coming from languages like C++ and Rust might find the generics-like syntax more confusing. They seem to expect more sophisticated features like "generic protocols".

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I don't have experience with this feature or the "generic protocols" feature from other languages. As a result, I found some of the pitch discussion difficult to follow.

I did find this rationale from the proposal compelling.

We believe that constraining primary associated types is a more generally useful feature than generic protocols, and using angle-bracket syntax for constraining primary associated types gives users what they generally expect, with the clear analogy between Array<Int> and Collection<Int> .

Associated types (and this syntactic sugar for them) are one of the most compelling reasons to use protocols in Swift. The proposal opens up new possibilities for API design in this space.
I trust the proposal authors when they say the "generic protocols" feature could be implemented using alternative syntax.

I would prefer using this syntax as the proposal describes, not for "generic protocols"

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I've followed the original pitch thread, read the original pitch and this updated proposal. I've thought about how I might use this in my own framework code.

1 Like

I knew that, so it looks like I didn't make my point clear enough.
There is also

which feels absurd. For me, it would make more sense to rule out generic protocols once and forever rather than introducing a brand new syntax because the obvious spelling is used for something else.
Generic classes use angle brackets, generic structs use angle brackets, generic enums use angle brackets, generic functions use angle brackets — but generic protocols should be written in a completely different way?

That would be more confusing than not having the feature at all, and I wish we would be more decisive instead of stirring false hopes.

3 Likes

(T,U) : Convertible is not modeling the relationship as a "generic protocol", it is modeling it as a binary relation over co-equal types. (Writing the types as a tuple is perhaps an abuse of notation, but it avoids needing to invent something totally novel.) Some people may only be familiar with languages where protocols are inherently a unary relation, and so allowing protocols to be generic seems like the only option for modeling a binary relation. I can understand why those people are frustrated by this proposal, because they feel like this is foreclosing on the possibility of modeling binary relations in the future. But there are languages that can directly express binary relations over types, such as Haskell, and that has significant benefits, especially for a language that centers member access as heavily as Swift. For example:

  • It would be natural for a binary protocol to have requirements (or extension functions) that are members of either type, or of neither.
  • It would be natural to be able to express that a binary protocol is commutative in its operands, avoiding boilerplate reversed conformances.
  • etc.
14 Likes

Strongly agree with what John says here. I think one can reasonably like or dislike the sugar aspect of this proposal, but the improvement for opaque results is very real, and it is definitely not closing us off from ever having "generic protocols".

+1 from me, simply on the basis of the new functionality for opaque results. It's a real weakness, this fixes it, and happens to make some other things easier to spell as a happy side-effect.

3 Likes

I think my biggest problem with the "looks like generics" aspect of the syntax is that it doesn't behave like generics.

  • Array<String> is a type
  • Collection<String> is not a type, but a constraint on one
  • any Collection<String> is a type again
  • some Collection<String> is kind of a type but only allowed in some syntactic positions

As a newcomer to Swift, I'm expected to see code like:

struct MyStruct {
    var array: Array<String>
    init(array: Array<String>) {
       self.array = array
    }
}

And understand that to generalize this, I cannot write:

struct MyStruct {
    var collection: Collection<String>
    init(collection: Collection<String>) {
       self.collection = collection
    }
}

Instead I have to write:

struct MyStruct<C: Collection<String>> {
    var collection: C
    init(collection: C) {
        self.collection = collection
    }
}

or maybe even

struct MyStruct {
    var collection: any Collection<String>
    init(collection: some Collection<String>) {
        self.collection = collection
    }
}

... all of which have significantly different meanings and performance characteristics?

And that's just the low-hanging fruit. I'm sure there are any number more examples where the chosen syntax creates landmines of understanding for beginners and experienced Swift programmers alike.

I'm no great fan of the C++-inspired generic syntax Swift has, but at least if we stick to angle brackets for type parameters and where clauses for constraints, it remains more-or-less consistent and more-or-less learnable.

I'd love to see some pie-in-the-sky thinking for "what could generics & protocols in Swift look like if we got rid of angle brackets and where clauses and did something novel, consistent and expressive! But I feel like this proposal is fighting the fundamentals of the language, to lose a very few characters compared to a more consistent syntax.

And yes, we need a syntax that allows us to write -> some Collection where ReturnType.Element == String, but we don't have to create this much confusion to do so.

3 Likes

These statements are all true of Collection itself, or any protocol. It's not new to this proposal.

2 Likes

I think that that response actually helps me understand this proposal more, as:

  • the distinction between types and protocols and the constraints they create is already confusing as hell,
  • [presuming you agree about multivariable typeclasses getting some unrelated syntax or never existing] this piece of syntax isn't useful for another purpose, and,
  • in general, in the positions in which it will be legal, for the protocols where it will see use, it will mean what you would expect, by analogy to a generic type conforming to the protocol

In fact, you basically wouldn't expect to use this for a protocol where an implementing type wouldn't use a generic for this associated type. So if we made OptionalProtocol, Wrapped would be a primary associated type, but eg. CodingKey would never be a primary associated type of Codable.

Am I starting to get it?

6 Likes

This proposal doesn’t lay out guidelines for how API designers ought to decide whether something ought to be a primary associated type. I imagine we’ll consider those guidelines if this proposal is accepted and there’s a follow-up to adopt it in the standard library.

That said, I think you’ve suggested a very interesting possible guideline: would a generic conforming type be likely to have that associated type as a type parameter? So, for example, a generic collection type would likely be generic over its element type, but it’s not likely to be generic over its index or iterator types.

7 Likes

I just did a small search for this term: Zero results — in the whole internet. So compared to generic protocols, this seems to be a much, much more advanced concept.
While I can (I think) deduct the meaning, I fail to see obvious solutions for details, like how to specify requirements. On the other hand, I don't think anyone would be confused by a generic protocol which acts like any other generic type, and it is crystal clear how they should work.

That is actually the major quarrel I have with ruling out generic protocols:
It creates an exeption for one of the building blocks of the language, as protocols can neither be generic, nor nested, which are very useful capabilities shared by all other types.
This proposal would be the last nail in the coffin for a significant unification, and even a slightly more powerful alternative can't change this.

2 Likes

This part of the proposal seems like it could cause confusion:

Note that default associated type witnesses pertain to the conformance, and do not provide a default at the usage site. For example, with GraphProtocol above, the constraint type GraphProtocol leaves Vertex unspecified, instead of constraining it to String .

Having the syntax show a default between the angle brackets and then that be the only place when used that it isn’t defaulted seems to violate the other symmetries that this proposal provides.

Separating the definition of an associated type from making it a primary associated type would also restore this symmetry.

It would also then reserve the = default type syntax to be used for a potential future ‘default generic type parameters’ enhancement. To give a concrete example, say Publisher were to add primary associated types. This could be declared as Publisher<Element, Error = Never> and would allow some Publisher<String> to be used instead of having to repeat Never any time a non-throwing publisher is used.

4 Likes

To look at this another way, I did a search for "generic protocols" (as opposed to "generic traits") and found mostly questions with accepted answers of "you want to use associated types" and articles about how to use protocols with associated types. I think when most folks want to make a protocol "generic," this is what they mean, and I think it's a great argument for the proposal as it stands.

It's also understandable that John's theoretical description of a non-existent feature has zero results. "Primary associated types" had zero results just recently, but is already becoming searchable. I assume that if/when such a feature is pitched, it will become searchable as well.

14 Likes

This is an interesting question, because the proposal does not provide any rationale for this choice.

Yet, the Publisher example you gave raises two questions:

  • Why should the designer of Publisher choose Never instead of Error as the default Failure type? I mean that I can understand why someone would like a Never default, probably because one uses a lot a never-failing publishers. But why is this case more important than users who use a lot of Error-failing publishers?

  • The proposal comes with an example:

    typealias SequenceOfInt = Sequence<Int>
    

    I conclude that you should be able to provide your own Never-defaulted publisher type:

    typealias YesPublisher<Output> = Publisher<Output, Never>
    func foo() -> some YesPublisher<Foo> { ... }
    

    Note that I'm not 100% sure that this would compile, because it's hard to keep track of all the discussed changes. But would this be acceptable to you (naming issue aside)?