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

This is supported in conjunction with the "opaque parameter declarations" proposal:

func foo(_: some Sequence<some Equatable>)

the some Equatable introduces a fresh generic parameter conforming to Equatable; stating it as a primary associated type means there is a same-type requirement between Sequence.Element and the fresh generic parameter here.

There is really no valid reason to constrain the Index associated type of Collection. I doubt that Index will be a primary associated type.

1 Like

Eventually, yes.

Consider the related example of an extension of Array<Optional<T>> because I think that illustrates the point better.

This can already be written as

typealias Foo<T> = Array<Optional<T>>
extension Foo { ... }

However that doesn't actually work. What is needed is some plumbing for "parameterized extensions", the fully-general form of which looks like this:

extension <T> Array where Element == Optional<T> {}

Once the necessary support for this is implemented in the declaration checker (with or without the fully-general syntax) then extension Sequence<some Error> should be made to work, since the semantics of some Error in this position is to introduce a fresh generic parameter which is equated to the primary associated type of Sequence via a same-type requirement.

I don't think so. The full generality of what you can state in a protocol means that it is hard to infer using heuristics alone what might constitute a primary associated type.

I'm not sure it's possible to formalize "dependencies" of this form in a meaningful way, and if it were, there might be multiple candidate associated types that could become primary, at which point you have a source order dependency, which is undesirable as discussed in the proposal.

I don't really agree with the philosophy here that it is acceptable to have feature A + feature B, or neither feature, but not feature A alone, for any values of A and B.

There are certainly many constructs in the language today that generalize in various ways, where the fully general syntax is not available in the surface language.

You're right, that's just a typo. I'll fix it.

It fully replaces it -- re-declaring an associated type in the body with the same name is an error. I'll clarify the proposal, thanks.

4 Likes

I’m still conflicting, leaning positive, on the proposal as a whole, but I already left comments about that on the pitch thread. I do want to call out this bit though:

Specifying fewer type arguments than the number of primary associated types is allowed; subsequent primary associated types remain unconstrained.

I’m not convinced this makes sense. The only example protocol with multiple primary associated types in the proposal is a hypothetical DictionaryProtocol, and while I could probably convinced that it’s more common to do something with DictionaryProtocol<String, some Any> than DictionaryProtocol<some Hashable, String>, I don’t think it’s clear at the use site that DictionaryProtocol<String> means the former. This also isn’t consistent with concrete types, where you are required to provide all the generic arguments (or _), or let them all be inferred. I think that’s the rule we should have for protocols too: if you want to use this shorthand syntax, you have to provide every primary associated type.

This does leave a question about writing some Any or _ to leave something unconstrained. Unfortunately I don’t have any great suggestions there.

23 Likes

Yeah, I agree honestly. I kind of got nerd-sniped into implementing the more general form (and of course it's a trivial change to require the exact number of arguments to be specified).

_ isn't quite what we want here because it means "specified but inferred from the expression". Imagine that we already allow any Sequence<Int>; then any Sequence means "existential Sequence with an erased type" whereas any Sequence<_> means "existential Sequence with an element type fixed to whatever makes this expression type check". They're different!

11 Likes

The vast majority of situations where I've wanted to use opaque types in Swift (outside of SwiftUI) have involved sequences or collections, so I'm very positive on this proposal even though it doesn't bring that to the standard library yet—getting closer is still important. I'll avoid restating smaller concerns that others have already expressed in the thread (like @jrose's above, which I agree with).

One small tooling-related concern: AFAIK, type parameters can't be documented today aside from free-standing prose in the description of the type. Since an associatedtype is its own declaration that can host a doc comment, moving an associatedtype into a primary type list means there's nowhere to hang that documentation anymore (e.g., Sequence.Element).

It would be great if something like the following, which doesn't work today, could be supported:

/// A type that provides sequential, iterated access to its elements.
///
/// - Parameter Element: A type representing the sequence’s elements.
public protocol Sequence<Element> {
  // ...
}

If Parameter feels like the wrong tag to use here, I'm not picky about the name—it would just be nice to make sure these can still be documented in a structured manner (and, while outside the scope of this proposal, extend this to concrete generic types as well).

27 Likes

Could you share an example that illustrates this difference?

Requiring either all or none of the arguments would help, but there may still be some confusion between the "default type witness" and a "default at the usage site". From the proposal:

A default type witness can be provided, as with ordinary associated type declarations:

protocol GraphProtocol<Vertex : Equatable = String> {}

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.

I still prefer that the associated type (with its documentation comment, protocol-composition type, and default type witness) be re-declared inside the protocol body.

4 Likes

This actually makes a lot of sense. I would actually go as far as suggesting that primary annotation in brackets should only have the associated type label (or labels) and not any other new syntax. All associated type functionality would be handled with previously existing syntax and the label annotation in brackets would be the only change to elevate an existing associated type as primary one.

That way less code needs to change, document tooling works as is, code is easier to understand as its more familiar, and there’s less new syntax that is essentially reinventing the same thing in new location.

This also makes code refactoring easier, if you want drop or add a primary associated type; you only need change the label and can keep the rest of the associated type declaration as-is.


protocol GraphProtocol<Vertex> {
    associatedtype Vertex: Equatable = String /// This is primary associated type defaulted to String
}

7 Likes

This is not a radical change to the proposal, and it solves quite a few issues indeed.

I'll just repeat what @benrimmington has said, but documentation would be free:

protocol Sequence<Element> {
  /// A type representing the sequence’s elements.
  associatedtype Element
}

And this would also help writing and formatting complex relationships between primary types:

// Free formatting of constraints on primary associated types
protocol Foo<A, B> {
  associatedtype A: Collection where
    C.Element: Hashable,
    C.Index == Int
  associatedtype B: Publisher where
    B.Output == A.Element,
    B.Failure: LocalizedError
}

Now, I also understand this idea comes with a problem. We probably would not force a redeclaration of a primary associated type when there is no documentation and no constraint:

// Should be OK, right?
protocol P<A> { }

// But now the programmer has to choose between
// the previous form and this alternative:
protocol P<A> {
  associatedtype A
}

Since a big language design constraint is that we don't want to foster dialects, having a choice between two syntaxes here sounds like a problem.

No matter what I agree that the review should address the documentation and freedom of formatting topics.

4 Likes

I would probably agree that this is a valid counter-argument, had I only used this argument to argue for the more general feature.

However, in my post I gave multiple other reasons why having the more general feature makes sense and why the arguments against it, presented by the proposal, are not that convincing to me and it would have been nice, if you could have elaborated more on that instead of just criticizing the last two sentences of mine.

I originally thought it’s always better to design the complex feature first and after that the sugar. However, I have changed my mind and now think this proposal is the right thing to do at this point of time, specifically:

  1. It’s not absolutely mandatory to do complex first, if we know that sugar isn’t going to change by the complex implementation. In this case I think it’s clear specifically what sugar the proposal wants, but not necessarily what exact complex syntax we want in all the special cases.

  2. It makes sense to prioritize most used features first and this proposal covers the most important cases. Complex usage cases outside this proposal are very niche and thus it’s good idea to postpone that to later, as that will require additional effort. So it’s about balancing priorities and I think this proposal strikes the right balance.

2 Likes

Still -1 on the proposed syntax for everything that has been said in the pitch thread as I do not want to rehash it here. One last thing though, it's very sad that any approach of trying to find a middle ground during the pitch time was completely rejected. Unfortunately the pitch thread already sneak peeked the position from the core team and the highly inevitable acceptance of this proposal, so in my opinion this particular proposal review is just required paper work. To clarify, this message is not meant to be toxic nor to offend anyone, I have zero intention to heat up a discussion. Thank you for noting.

5 Likes

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