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

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)?

The actual concept I was describing is a binary relation. The Wikipedia page for that comes up immediately if you search for that phrase. Tino apparently did an exact-phrase search just to make a point.

6 Likes

Yes - Publisher probably wasn't the best example because, as you say, there are reasonable arguments for what the default should be. My main point was that by doing as @Moximillian and @benrimmington have suggested and keeping the associated type declaration inside the protocol body (with it then being made a primary associated type by also mentioning its name inside the angle brackets), we keep the = Type syntax available for 'default at usage site' in the future.

2 Likes

Quick thought: for the same reason as why we don't annotate every non-throwing ordinary function with nothrows before the result type signature's ->.

5 Likes

And also because the only other available default, Error, would be a terrible default.

1 Like

Not with Lazy in its current form. You still need to surface enough information to drive the conditional conformance. In the case of LazyMapCollection, that conditional conformance is driven by a generic parameter Base, the collection the lazy wrapper wraps. When that wrapped collection is bidirectional, so is the lazy collection.

If you just returned an opaque some Collection<Element> that happened to be a LazyMapCollection<[Int]> then the information to drive the conditional conformance is not available to the caller (even if the compiler sometimes "knows" it for optimization purposes, if the function is inlinable), so no bidirectional conformance.

This is kind of a fundamental conflict. You cannot both keep secrets, and provide functionality based on those secrets. There has to be some give and take. There's a simpler example of this: if you are returning an [Int] but you actually return some Collection<Int> you're holding back some really useful information from the caller. They would probably love to have an Array, or at very least some RandomAccessCollection<Int>, but in order for you to preserve future flexibility to return something different, you're not giving information of which they could otherwise take advantage.

A middle ground could perhaps be to create a protocol that provides just enough information to drive the conditional conformance, while reserving other flexibility:

extension Collection {
  var lazy: some LazyCollection<Self>
}

// A protocol that provides lazy versions of common
// Collection operations.
protocol LazyCollection<
  Base: Collection
>: Collection where Element = Base.Element, Index = Base.Index {
  var base: Base { get } // unwrap the laziness of the collection
  func map(...) -> some LazyCollection<Self>
}

and then, with one additional language feature we don't yet have, do something like this:

// note this next part is not currently valid Swift,
// you can't retroactively conform protocols to other
// protocols, conditionally or otherwise
extension LazyCollection: BidirectionalCollection 
  where Base: BidirectionalCollection { }

This would allow you to get the conditional conformance benefits without exposing concrete implementations like LazyMapCollection , LazyFilterCollection.

Note that unlike Collection, this chooses a whole collection, Base, as the primary associated type for LazyCollection, not just the Element type. This allows for the unwrap operation to return the original eager collection, and follows the pattern we've a few times where primary associated types usually match generic arguments of concrete types implementing the protocol.

Of course, this is quite a lot of effort to go to purely to hide the type implementation details of things like LazyMapCollection. It may well not be worth it, especially as the Lazy types in the standard library are fragile so you don't get the ABI benefits, only the "hide the types" benefit. And the nesting e.g. some LazyCollection<some LazyCollection<some LazyCollection<[Int]>>> still gets exposed. But it may be more compelling in other similar cases.

6 Likes

Observation while drafting the adoption of this feature in the stdlib: having all associated types defined using consistent syntax is really valuable. It would be a shame to lose this consistency.

The associatedtype declaration serves as a useful place to attach API documentation, declaration modifiers like the (undocumented) override attribute, and it's just nice to have all of the associated types defined in the same way.

Example 1: Sequence (Doc Strings)

(This is the same issue as @allevato has raised above.)

We currently have:

public protocol Sequence {
  /// A type representing the sequence's elements.
  associatedtype Element

  /// A type that provides the sequence's iteration interface and
  /// encapsulates its iteration state.
  associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
}

This is nice and symmetric, everything is consistent.

As proposed, this would now look like:

public protocol Sequence<Element> {
  /// A type that provides the sequence's iteration interface and
  /// encapsulates its iteration state.
  associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
}

We no longer have an obvious place for Element's documentation.

Of course, we can still put it on the name itself, but this makes the declaration less readable:

public protocol Sequence<
  /// A type representing the sequence's elements.
  Element
> {
  /// A type that provides the sequence's iteration interface and
  /// encapsulates its iteration state.
  associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
}

(Sometimes the doc string will have multiple paragraphs of text. Having it interrupt the protocol declaration would be a shame.)

One solution would be to integrate it into the doc string of the protocol itself in some way. This would work, but it makes the primary associated type even less similar to the rest of the associated types:

/// <Long discussion on what Sequence is all about>
///
/// - Associated type Element: A type representing the sequence's elements.
protocol Sequence<Element> {
  /// A type that provides the sequence's iteration interface and
  /// encapsulates its iteration state.
  associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
}

Example 2: Collection (Associated Types with Modifiers)

Collection refines Sequence, and it wants to have the same primary associated type, Element.

In the current version of the stdlib, Collection redeclares Element with the (undocumented!) override modifier, to help associated type inference:

public protocol Collection: Sequence {
   // FIXME: Associated type inference requires this.
  override associatedtype Element
}

There is no obvious place to put such a modifier in the primary position:

public protocol Collection<Element>: Sequence {
}

Of course, we don't need such a modifier if Collection<Element> has the same semantics as the override associatedtype Element declaration. Which leads us to the last example:

Example 3: RangeReplaceableCollection (Inherited Associated Types)

RangeReplaceableCollection refines Collection, without explicitly overriding Element.

public protocol RangeReplaceableCollection: Collection {
   ...
}

If we want it to have the same primary associated type, we'll want to write:

public protocol RangeReplaceableCollection<Element>: Collection {
  ...
}

However, will Element here refer to the existing associated type that was inherited from Sequence, or will it introduce a new associated type with the same name?

Recomended Solution

My preferred solution would be to leave the existing associatedtype declarations as is. The bracketed syntax is a nice way to designate one of the associated types as the primary, but it should not define the associated type; it should just reference it.

public protocol Foo<Bar> { // error: Unknown associated type 'Bar'
}

For cases like Sequence, we would be able to keep the doc string where it belongs:

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

  /// A type that provides the sequence's iteration interface and
  /// encapsulates its iteration state.
  associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
}

For cases like Collection, we wouldn't need to figure out a new way to attach modifiers:

public protocol Collection<Element> {
   // FIXME: Associated type inference requires this.
  override associatedtype Element

  /// A type that provides the sequence's iteration interface and
  /// encapsulates its iteration state.
  associatedtype Iterator: IteratorProtocol where Iterator.Element == Element
}

For cases like RangeReplaceableCollection, it would be clear that the declaration of the primary associated type isn't going to break ABI by adding a new associated type to the protocol:

public protocol RangeReplaceableCollection<Element>: Collection {
  ...
}
31 Likes

I can't let this passage go unnoticed:

However, duplicating the associated type declaration in this manner is still an error-prone form of code duplication, and it makes the code harder to read. We feel that this use case should not unnecessarily hinder the evolution of the language syntax. The concerns of libraries adopting new language features while remaining compatible with older compilers is not unique to this proposal, and would be best addressed with a third-party pre-processor tool.

(Emphasis mine)

Library authors should not be considered second class citizens. Their concerns ought to be considered just as important as the concerns of the clients of said libraries. Library maintainers are people too; there is a limit to how much pain they are willing to suffer.

Forcing us to reach for preprocessing tools means that we're going to lose basic modern software engineering features, like intelligent code completion / syntax highlighting, structural editing, etc. etc. This will lead to lower quality output.

It will also make library code be even less similar to regular Swift code, making contributions that much more difficult.

(That said, most Swift packages have the option to simply bump their required toolchain version in a minor release, which resolves this pain.)

25 Likes

6 posts were split to a new topic: Language downgrade tool for source libraries

If anything then this should look like this:

public protocol RangeReplaceableCollection<Element>: Collection<Element> {
  ...
}

This would align with the declaration side of generic classes which like to forward its subclass' generic type parameter to the generic superclass.

1 Like

I don’t want to derail this thread with an extended discussion about a downgrade tool. I’ve moved some of these posts to a new discussion thread.

I will say here that, while the Core Team understands the plight of source library maintainers, we are uncomfortable with accepting a restriction that all declaration syntax must be designed around the ease of #ifing it.

8 Likes

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

Very much so! I'd like to adopt AsyncSequence in various projects instead of other reactive solutions, but the ergonomics just make it impossible, as depicted in the motivational section of the proposal, without falling back to a boilerplate type-erasure solution, like Combine does.

1 Like

Overall +1 , Big improvement on cognitive load when reading and writing generic functions.

Regarding @rethrows — I don’t think it has been explicitly addressed in the proposal or this thread and it probably needs to be.

This proposal does not provide a solution for writing effects constraints for rethrowing protocols. This is a general problem with both opaque result types and generic code that wants to generalize over an effect, independent of primary associated types. John's write up here outlines the limitations on the current form of rethrows and possible language solutions to lift those limitations:

In my opinion, this problem should be solved as part of the rethrowing protocols proposal (or possibly subsumed by a "typed throws" proposal), which hasn't gone through a formal review yet. Depending on the language design for generalizing rethrowing protocols, effects constraints could either compose with opaque result types via keyword, e.g. (strawman!) some throws(ErrorType) AsyncSequence<Element> or via normal same-type constraints. For example, a precise error associated type could become a primary associated type of AsyncSequence, e.g some AsyncSequence<Element, MyError>. If the latter is the solution, we might want a way to default the constraint to Never, similar to how not writing throws means the default is non throwing.

So, the important thing to figure out for this proposal is whether the current design leaves room for these future language features. I can think of a few implications:

  • This proposal does not preclude the ability to add an effects specifier to opaque result types via keyword, e.g. some throws P.
  • If the model for rethrowing protocols transitions to use an associated type, we probably want to leave room for defaulting the constraint on a primary associated type. To me, that suggests that the proposal should remove the ability to add a default type witness for a primary associated type in angle brackets via =. If effects constraints are deemed not common enough to warrant lifting the error associated type to be "primary", perhaps that's motivation for generalized opaque result type constraints, which is also not precluded by the proposed design.
  • Currently, the default effect for code that's generic over a rethrowing protocol conformance is throws. If we want the syntax some AsyncSequence<Element> to default to non-throwing, we should not adopt primary associated types for AsyncSequence until there is a way to express that default. This proposal does not include adopting primary associated types in the standard library (that will be a separate proposal and discussion) so this proposal doesn't preclude that either.
7 Likes

I feel like this example isn't a great fit for the problem being discussed. In my opinion some AsyncSequence<Element> should equal some AsyncSequence<Element, Error> and not some AsyncSequence<Element, Never>. This would be symmetrical to the fact that non-throwingness would require explicit Never or the explicit absence of throws if you look at non-throwing functions etc., while the regular throws without a concrete error type will very likely be the short form for throws(Error). On top of that AsyncSequence in particular would require an associatedtype Failure: Error = Error extension to avoid any breakage.

I think you have that backwards. In general in Swift, things default to not throwing and you have to write something extra to allow them to throw. That is a key part of our effects design: if you default to allowing an effect (like mutating, throws, or async), you have an immediate problem where lots of code and abstract interfaces unintentionally declare that they allow the effect, which can be difficult or impossible to clean up later. In contrast, if you have to opt in to declaring an effect, it’s very likely that you’ll quickly converge to opting in at all the right places, because even a basic implementation will fail to compile otherwise.

That corresponds to types like Task defaulting to an error type of Never. Naively, at least, it seems like AsyncSequence ought to be the same way. I agree that the fact that we’d need to treat bare AsyncSequence as throwing complicates this. Still, that just confirms to me the wisdom of leaving this open as Holly suggests so that we can consider it holistically with whatever language approach we decide on for throwing and non-throwing conformances.

14 Likes

I don't disagree with your reasoning and what Holly was previously analyzing. I just wanted to highlight that AsyncSequence is a bit of a special example that in my opinion does not fit well to describe this problem. Let me put this differently. Yes I agree that if AsyncSequence would already have an associated type for Failure, then it would make total sense to have some AsyncSequence<Foo> the same as some AsyncSequence<Foo, Never>. However since this isn't the case, and as far as my understanding goes on the future extension of this protocol with the missing Failure associated type, we have to provide a default which would mirror the todays behavior, which makes the bare some AsyncSequence a throwing type. Therefore I think it's a bit of an unlucky situation we have now. If I'm totally wrong on my expectations, then feel free to ignore my conversation regarding this particular issue.

I don't think an absence is explicit. In fact, I think a quality expressed by the absence of some trait is the definition of implicit.

Well, that is all what we have, so it's no wonder no one suggests using a feature that does not exist.
On the other hand, the query "relation over co-equal types" was not even meant to be about a existing or hypothetical feature, but about the concept.

Sorry to pick a specific example — there are some more, and I don't think speculation about what people might want is a good argument. On the other hand, there a quite some people who definitely want generic protocols, and I would not assume that this is not an informed opinion.

There has been some demand for examples in this thread, and I realized one compelling case is still missing (or maybe I just did not see it... or it is not that compelling :grimacing:):

The whole family of Any-types (AnyIterator, AnyPublisher...) and manual conversion is just needed because the necessary generic parameter can't be applied to a protocol.
If we had that capability, all those glue types could be removed, and instead all actual types could (automatically) conform to generic protocol. That protocol would not have associated types, so you would not suffer from their limitations.
That is, you could define and use properties like

let numbers: AnyIteratorProtocol<Int>
// ....
   numbers = someIntArray.makeIterator()