[Pitch 2] Light-weight same-type requirement syntax

So to clarify:

  • Adding a new associated type is binary and source compatible, as long as it has a default
  • Making an existing associated type primary is binary compatible; also source compatible as long as it's added at the end of the list
  • Making an existing primary associated type non-primary is binary compatible but source breaking
  • Re-ordering primary associated types is binary compatible but source incompatible
  • Re-ordering non-primary associated types is binary compatible and source compatible
  • Removing an associated type entirely is binary incompatible and source incompatible
7 Likes

Note that you only need to do the #if dance if the library supports compiler versions that cannot parse the primary associated type syntax. So, if a library compatible with the Swift 5.6 compiler adopts primary associated types and then decides to add another primary associated type later, the library does not need to add another #if condition for the second primary associated type.

I would really like to see this handled in a way that doesn't require duplicating the entire protocol body. IMO that imposes a pretty high maintenance burden on library authors to keep the different versions in sync. But I'm also not responsible for maintaining such a library so perhaps I am overreacting?

Is there a way to specify just the second of two primary associated types? It seems a bit strange to me to impose a de facto hierarchy on the primary associated types based on order. (Would placeholder types allow this to 'just work' as some AsyncSequence<_, MyError>?)

1 Like

As a maintainer of a library, I certainly wouldn't want to support such a bifurcation. I don't know if it's valuable enough to immediately drop older compiler support. Alamofire's ResponseSerializer could take advantage of it, but so few people use anything other than the built in serializers I don't know if it's that important to support quickly.

(If Apple wants us to jump to newer Swift versions faster, they need to support older macOS versions longer.)

6 Likes

That should just be done with the full where clause syntax. Remember that the primary associated types feature is not intended to replace where clauses entirely; you still need them for more complex requirement specifications.

This could be made to work as long as primary associated type constraints are only valid in generic requirement position, but it introduces an ambiguity as soon as we allow primary associated types constraints on any for the types of values; the placeholder means "infer this from context", not "leave this unspecified". That is,

let a: Array<_> = [1, 2, 3]

infers the type of a as Array<Int> from the expression, it doesn't erase the element type to give you a hypothetical <T> Array<T> existential. Similarly, you would expect that

let a: any Sequence<_> = [1, 2, 3]

would infer the type of a as any Sequence<Int>, not any Sequence with Element erased.

4 Likes

If it is any consolation, primary associated types do not require any runtime support nor do they introduce new ABI, so as long as you can use the new compiler you can still backward deploy code that uses the feature to older platform versions.

4 Likes

That doesn’t really matter when people can run the version of Xcode required. macOS is technically Swift’s least supported platform, as far as versions and actually being able to ship software go.

2 Likes

I don’t think it’s reasonable to accept minimizing #if complexity as an ongoing factor on language evolution. I’d be interested in knowing if there are other ways we can address this backward-compatible source library use case, though. In particular, when we’re printing module interfaces, we do have logic to emit #if conditions to allow the interfaces to be parsed by older tools. That logic isn’t perfect, but it might be a foundation for doing the same rewrite to arbitrary source. So we could have a tool that does a source-to-source translation and redundant emissions necessary to make code interpretable by older compilers. Of course, maintainers would then have to actually run that tool when packaging their library for distribution.

6 Likes

I'm sorry, but I do not understand. What would library maintainers have to do, for which purpose?

I mean that we could make a source tool that turns e.g.

protocol Translator<Input, Output> {
  func translate(_: Input) -> Output
}

into:

#if $ProtocolPrimaryAssociatedTypes
protocol Translator<Input, Output> {
  func translate(_: Input) -> Output
}
#else
protocol Translator {
  associatedtype Input
  associatedtype Output
  func translate(_: Input) -> Output
}
#endif

for the purposes of supporting source library maintainers who want to support generating versions of their libraries that work in older versions of the compiler. Basically, a new version of the compiler would compile the library into source that can be compiled by older compilers.

Of course, maintainers would then have to run that tool in order to publish versions of their library instead of just having clients check out a tag of their repository. And they would also want to test that the output actually worked on older tools, but that's presumably not a new requirement.

The advantage is that, assuming the tool works, you get to just write code to the latest version of the compiler without having to manually maintain redundant declarations or whatever other #if complexity is necessary to support older compilers. The disadvantages are that you need the tool to exist and you need a sort of compilation phase to distribute backward-compatible versions of your library.

4 Likes

The package manager is also growing more support for custom build steps and build plugins, which might make custom preprocessing to strip primary associated types a tolerable option.

3 Likes

It might have to be on the maintainers’ side, though. Depends on why you want to support older tools. If clients are willing to update secondary tools as long as they don’t have to update the compiler and thus potentially their own code, then yeah, a client-side build step is fine. Otherwise it’s just as much of an imposition. I don’t think a general downgrade tool is likely to be small enough that people could just check it into their source trees.

1 Like

I see. But why not choose a simpler solution that does not require source rewriting and complex packaging solutions?

I would not advise looking for an SPM-only solution. There are other packaging systems in the eco-system, such as CocoaPods and Carthage, with many years of maturity, that SPM can not always fully replace. Opposing packaging systems is the last of our needs.

4 Likes

Spent some time with a recent snapshot and was really pleased with the experience! Great work, everyone! :smile:

One thing that came up was that composed protocols don't seem to be supported. E.g., should this work?

func f() -> some (A<Int> & B<Int>) { … }

This also made me wonder if "generic" type aliases should support primary associated types. E.g.:

typealias C<T> = (A<T> & B<T>)

func f() -> some C<Int> { … }

If so (or not), it would definitely be nice for the pitch to address things.

Edit: One other question is how does one document a primary associated type? Sequence can document Element as:

A type representing the sequence's elements.

But if it were redefined as Sequence<Element>, where would this documentation go?

7 Likes

Yeah, that doesn't work today because of implementation limitations. It's considered a bug.

Yep, that's another limitation I need to address.

I guess you would need to document it as part of the protocol itself, just as concrete types document their generic parameters.

2 Likes

This is how the documentation of associated types is currently accessible:

The typealias Element = Element makes it sound like you could maybe do something like this?

protocol Collection<Element> {
  /// A type representing the sequence's elements.
  typealias Element = Element

  ...
}

You're seeing the typealias Element inside the Array type, not the associated type in the protocol. Re-declaring a typealias with the same name as a (primary) associated type in the protocol should probably not be legal, because then it introduces an ambiguity in name lookup. The same scenario is not a problem for concrete types, because the generic parameter 'Element' on the right hand side of 'typealias Element = Element' is not semantically a member of Array; if it weren't for that typealias, I could not write 'Array.Element' to access the generic parameter.

2 Likes

Ah, is this something the compiler does automatically for concrete types that conform to protocols? There doesn't seem to be an explicit typealias in the source code for Array.

Yeah. An associatedtype constraint is a constraint that a member type exists, and a declaration for it is added if the associated type is inferred from witnesses.

1 Like

This is needed. SE-0358 documents the difficulty that will sometimes occur when choosing a primary associated type:

Unless "does not" is changed to "will never be able to"…

…I'd wait for that to get fixed, rather than potentially making a wrong choice that can never be corrected.

LazySequenceProtocol<Element: Int>
LazySequenceProtocol<Elements: [Int]>
3 Likes