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

A bit of a nit, but I think the formal grammar in the proposal has an error:

  • primary-associated-type-list< primary-associated-type | primary-associated-type , primary-associated-type-list >

The shape of this recursion repeats the angle brackets. That is, it disallows Foo<Bar, Baz> and requires Foo<Bar, <Baz>>.

3 Likes

I’m tentatively liking this proposal.

The need to write things like Sequence<String> is real and urgent. Existing approaches present some of Swift’s nastiest sticking points for those not deeply invested in understanding its type system. There’s a progressive disclosure cliff, as it were, in this area of the language.


I wondered a bit about a “named parameter” syntax like Sequence<Element: String>, with no change to protocol declaration syntax. This has two advantages: (1) no need to create this new notion of “primary” associated types, and (2) more obvious correspondence between the different associated types within a protocol. It does bug me in the proposal how much protocol Sequence<Element> looks like a generic protocol, and how much it hides the fact that Element is in fact exactly the same sort of beast as other associated types with the single exception of this convenience at the point of use.

However, the syntactic correspondence between Sequence<String> and Array<String> is compelling for usability reasons. Ultimately, even though it is more concise than Sequence<.Element == String>, the Sequence<Element: String> syntax still places knowledge burden about the protocol’s structure and about type system esoterica on library clients instead of library authors. So…yes, Sequence<String> for usability.


Despite that, I’m a bit uneasy about this new notion of “primary” associated types, and explicitly designating it. Without having thought hard about it at all, I wonder if there isn’t some way to infer from the protocol’s structure itself which types are primary?

For example, consider the declaration of Sequence:

public protocol Sequence {
  associatedtype Element
  associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
  …
}

Iterator depends on Element, but not vice versa. In the (two-node) dependency graph of associated types, Iterator has incoming edges, but Element does not.

One could (I think?) simply move the type constraint and get an equivalent protocol:

public protocol Sequence {
  associatedtype Element where Iterator.Element == Element
  associatedtype Iterator: IteratorProtocol
  …
}

But there is note of authorial intention in the decision to make Iterator’s declaration depend on Element instead of the reverse. Is that sufficient to infer that Element is primary?

Even as I type this out, I see some arguments against it: too much magic, too little syntactic correspondence between the protocol’s declaration and Sequence<Foo>. (Where’d the angle braces come from?) Still, I’d be curious to hear the authors’ own thoughts on the philosophy behind what it means for an associated type to be “primary,” the burden of introducing this additional concept, and in particular why inferring primary-ness is a bad idea. This might help ease my discomfort with introducing yet another entity to Swift’s type system.

6 Likes

I find this syntax a bit misleading cause usually when working with types, the colon means ‘conforms to’:

struct IntWrapper<Value: FixedWidthInteger> // …
function areEqual<T: Equatable>(a: T, b: T) -> Bool // …
2 Likes

Agreed, yet another reason the proposal’s syntax is the one to use.

1 Like

I'm -1 on this proposal as it stands right now.

At the risk of repeating what others already have said, I want to add my two cents here as to why I'm not in favor of this proposal.

As many others I am of the opinion that this proposal does two things in one:

On one side it gives us a powerful new feature, namely the ability to constrain associated types of opaque result types (which later will – hopefully – be generalized to existentials as well). It basically solves the problem that currently there is no way of writing the following (syntax is obviously made-up):

func foo() -> some Sequence where returntype.Element == String {
    // ...
}

I think that nobody claims that we do not need this feature, so this part of the proposal is fine.

However, the proposal comes with a catch: it gives us this feature with a really sugary-feeling syntax that makes the most common use cases possible but excludes some use cases that are more niche. Namely, it will only be possible to constrain associated types that are deemed to be 'primary' by the API designer.

I would much rather see the syntax that uses the names of the associated types as is described in the alternatives suggested and that most people who are currently against this proposal are reaching for:

func foo() -> some Sequence<.Element == String> {
    // ...
}

IMHO, this syntax has very clear advantages:

  • it is possible to constrain every associated type if necessary
  • it could also be used to constrain the associated type to a protocol (e.g. some Sequence<.Element: BinaryInteger>)
  • it is very similar to writing constraints in a where clause
  • it works with protocols that haven't adopted primary associated types yet

If we should choose this syntax, we could still add primary associated types + the sugar syntax alongside it (which then really is sugar and doesn't just feel like it), which would render most arguments against the more general syntax obsolete:

  • No visual clues at the protocol declaration about what associated types are useful.

    there are still primary associated types that help in guiding the API user towards the right types to constrain

  • The use-site may become onerous. For protocols with only one primary associated type, having to specify the name of it is unnecessarily repetitive.

    it will be a rare occasion that you have to use the general syntax (if the API designer has chosen the primary associated type(s) well) – if you have to do it, then it won't be a problem that the use-site becomes a bit onerous perhaps. This is the case with many features that have sugar syntax. Imagine having to write all optionals as Optional<String> instead of String? – it still is useful that the more general syntax exists.

  • This more verbose syntax is not as clear of an improvement over the existing syntax today, because most of the where clause is still explicitly written. This may also encourage users to specify most or all generic constraints in angle brackets at the front of a generic signature instead of in the where clause, violates a core tenet of SE-0081 Move where clause to end of declaration.

    I don't understand the first part of this argument at all. There is no existing syntax for this feature today, at least when it's used for opaque result types (and existentials).
    I also don't think that it is a bad thing when the user can decide if they want to write their generic constraints at the beginning or at the end of a function. Already today there is the choice to write a function like this:

    func foo<S1: Sequence, S2: Sequence>(_ s1: S1, _ s2: S2) {}
    

    But many people still choose to write it like this instead:

    func foo<S1, S2>(_ s1: S1, _ s2: S2) where S1: Sequence, S2: Sequence {}
    

    It depends on what style of programming you like and how long your constraints are and I don't think that pushing users to one of those styles is important here.


All in all I would say that I'd be in favor of this proposal if it actually adds the whole feature that the proposed syntax implicitly sugars. If we have that complete feature then I would be more than happy to also have the sugar alongside it.

11 Likes

@John_McCall writes in https://forums.swift.org/t/pitch-2-light-weight-same-type-requirement-syntax/55081/180:

3 Likes

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