- Authors: Pavel Yaskevich, Holly Borla
- Status: Awaiting implementation
Introduction
As a step toward the goal of improving the UI of generics outlined in Improving the UI of generics, we’d like to propose a couple of improvements that bridge the syntactic gap between protocols and generic types, and hide some of the complexity (both visual and cognitive) of writing same-type constraints on associated types and type parameters of generic types.
Motivation
Consider a global function concatenate
that operates on two arrays of String
:
func concatenate(_ lhs: Array<String>, _ rhs: Array<String>) -> Array<String> {
...
}
On a path to generalize such a function, a reasonable next step could be to declare it on Array
itself via an extension and remove the left-hand side argument, because it could be supplied by the instance of an array it was called on:
extension Array {
func concatenate(with: Array<String>) -> Array<String> {
...
}
}
As written, concatenate
becomes available on all arrays, which is not what we really want. We want this new method to be available only if the element type of an array is String
. Currently, the way to express that is via where
clause on the extension:
extension Array where Element == String {
func concatenate(with: Array<String>) -> Array<String> {
...
}
}
Using a where
clause is a different spelling from use-site where array of strings is expressed simply via Array<String>
, or [String]
to be concise. Aside from different spelling, the where
clause forces the developer to lookup the name of a generic parameter expressed by angle brackets. That is made worse for more complex types, where there are more than one generic parameter that has to be constrained and the where
clause itself could become quite large.
This problem is also exacerbated the more generic concatenate
gets. Let’s try to make it so concatenate
operates on a Collection
instead of an Array
:
extension Collection where ??? == String {
func concatenate(with: Collection<String>) -> Collection<String> {
...
}
}
There are a couple of issues here. First, we need to figure out what type to constrain to String
and secondly, the language doesn’t allow Collection<String>
with following error message:
error: cannot specialize non-generic type 'Collection'
First issue could be addressed by looking at the Collection
protocol declaration and figuring out which of multiple associated types could be used as a viable constraint. But second issue requires a complete re-design of the concatenate
declaration and learning multiple new things about protocols including type- and value-level abstraction, existential types, constraining associated types with where clauses, etc. This proposal aims to simplify same-type constraints on associated types.
Proposed Solution
We’d like to propose two enhancements (one building on the other) to the language to help with progressive disclosure for “generalization” refactorings and improve language ergonomics around generics in general.
Let’s start with associated types. Protocols can have one or more associated types, they serve a role similar to that of generic parameters in generic types such as structs and classes. In many day-to-day development situations, it’s not necessary to know exact semantics and differences between associated types and generic parameters; that understanding could be acquired gradually. In our previous example with Collection<String>
and Array<String>
, the angle-brackets should intuitively mean the same thing - a collection containing a number of elements of type String
.
To accommodate this intuition, we propose to allow protocols to express primary associated type(s) in the same way they are declared in a generic type:
protocol Collection<Element> {
associatedtype Index: Comparable
...
}
This is syntactic sugar for:
protocol Collection {
associatedtype Element
associatedtype Index: Comparable
...
}
Building on that new capability of protocols, we propose a new light-weight way to declare constrained extensions that matches syntax used in function and other declarations involving generic types:
extension Array<String> {
...
}
extension Collection<String> {
...
}
Is syntactic sugar for declarations with where
clause:
extension Array where Element == String {
...
}
extension Collection where Element == String {
...
}
This new syntax can be used to simplify where
clauses with same-type constraints more generally. Instead of having to write the same-type constraint in a where clause separately from the name of the protocol, use angle brackets hide all that complexity, which is exactly how classes are spelled today:
extension Array where Element: Collection<String> {
...
}
func action<T: Collection<String>>(_: T) {
...
}
Each of the above declarations have implicit constraints in the where
clause, expanding where
clause to be:
extension Array where Element: Collection, Element.Element == String {
...
}
func action<T>(_: T) where T: Collection, T.Element == String {
...
}
Detailed design
Under this proposals, protocols may declare one or more primary associated type in angle brackets at the protocol declaration. Expressing same-type constraints on primary associated types in angle brackets is narrowly scoped to extension
declarations and generic constraints in angle brackets and where
clauses. This syntax cannot appear in any other position; existential types - func test(_: Collection<String>) → ...
and opaque types - func test(...) -> some Collection<String>
are not supported under this proposal, because that requires additional expressive power that does not exist in the language today. Please refer to the Future Directions section for more details.
Types in angle brackets are connected to the associated types via same-type (==
) constraint, in accordance with the principle of the least surprise. In other words, this angle bracket syntax behaves the same as it does for generic types. If the concrete primary associated type specified is a class, this is also transformed into a same-type constraint, rather than an inheritance constraint.
When type-checker encounters a protocol
declaration that uses new syntax, it would implicitly convert angle brackets into a number of associatedtype <Name>
declarations preserving information that each converted name could be referred in angle brackets at the use-site. At the use site, the user may specify none of the primary associated types, or all of them in angle brackets.
For extension
declarations, the type-checker would either add a new implicit where
clause, or use the existing one to add the same-type requirements matching stated types with corresponding associated types preserving source information of the type references.
Given following protocol declaration:
protocol MyProtocol<Element, Index> {
associatedtype Constraint
}
It would be possible to declare an extension that constrains both associated types via new syntax:
extension MyProtocol<String, Int> { ... }
It is a syntactic sugar for:
extension MyProtocol where Element == String, Index == Int { ... }
Existing constrained extensions could be converted into new syntax as well, for example:
extension MyProtocol where Element == String,
Index == Int,
Constraint: BinaryInteger { ... }
Becomes a more concise:
extension MyProtocol<String, Int> where Constraint: BinaryInteger { ... }
Another areas where new syntax aids with ergonomics are where
clauses and generic constraints like this:
extension Array where Element: MyProtocol<String, Int> { ... }
func doSomething<T: MyProtocol<String, Int>>(_: T) { ... }
New syntax supplements the conformance constraint with a number of ==
parameters so existing semantics are preserved. This aligns with the goal of UI ergonomics and bridges the syntactic gap between classes and protocols:
extension Array where Element: MyProtocol,
Element.Element == String,
Element.Index == Int { ... }
func doSomething<T: MyProtocol>(_: T) where T.Element == String,
T.Index == String {
...
}
Primary associated types cannot be inherited, just like regular generic parameters, they have to be explicitly specified in protocol declaration, for example:
protocol MySubProtocol: MyProtocol {
}
Declaring MySubProtocol this way means that it has no primary associated types, although its parent has two (Element and Index), so attempting to declare following extension in invalid:
extension MySubProtocol<Int, Int> {
...
}
In order to make aforementioned extension valid, MySubProtocol would have to list of all of its primary associated types explicitly and provide their association with MyProtocol if necessary at the declaration site:
protocol MySubProtocol<MyElement, MyIndex>: MyProtocol<MyElement, MyIndex> {
...
}
This is a syntactic sugar for the following declaration:
protocol MySubProtocol: MyProtocol where MyProtocol.Element == MyElement,
MyProtocol.Index == MyIndex {
associatedtype MyElement
associatedtype MyIndex
}
Alternatives considered
Annotate regular associatedtype
declarations with primary
Adding some kind of modifier to associatedtype
declaration shifts complexity to the users of an API because it’s still distinct from how generic types declare their parameters, which goes against the progressive disclosure principle, and, if there are multiple primary associated types, requires an understanding of ordering on the use-site.
Use the first declared associatedtype
as the primary associated type.
This would make source order load bearing in a way that hasn’t been in the past, and would only support one associated type, which might not be sufficient for some APIs.
Require associated type names, e.g. Collection<.Element == String>
Explicitly writing associated type names to constrain them in angle brackets has a number of benefits:
- Doesn’t require any special syntax at the protocol declaration.
- Explicit associated type names allows constraining only a subset of the associated types.
- The constraint syntax generalizes for all kinds of constraints e.g.
<.Element: SomeProtocol>
There are also a number of drawbacks to this approach:
- No visual clues at the protocol declaration about what associated types are useful.
- The use-site may become onerous. For protocols with only one primary associated type, having to specify the name of it is unnecessarily repetitive.
- 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, which goes against SE-0081.
Source compatibility
This proposal has no impact on existing source compatibility. For protocols that adopt this feature, adding or reordering the primary associated types will be a source breaking change for clients.
Effect on ABI stability
This change does not impact ABI stability.
Effect on API resilience
This change does not impact API resilience. Lifting an existing associated type to be primary is a resilient change. Adding a completely new associated type has the same resilience impact as adding an ordinary associated type.
Future Directions
It would be useful to enable new syntax in more structural positions such as:
- Existential types, e.g.
func test(_: Collection<String>)
- Opaque result types. The proposed syntax provides a natural evolution path for opaque types because it allows them to state associated type requirements without any cumbersome new syntax, such as a second generic signature, e.g.
func evenValues<C: Collection>(in collection: C) -> <Output: Collection> Output where C.Element == Int, Output.Element == Int
.
To further improve the experience of generalizing a concrete API, we could also allow some
on parameter types to indicate an implicit type parameter conforming to the specified protocol. Combined with this proposal, it would allow the following signature (which isn’t even expressible in Swift today):
func evenValues<C: Collection>(in collection: C) -> <Output: Collection> Output where C.Element == Int, Output.Element == Int
to be simplified to:
func evenValues(in collection: some Collection<Int>) -> some Collection<Int>
Acknowledgments
Thank you to Joe Groff for writing out the original vision for improving generics ergonomics — which included the initial idea for this feature — and to Alejandro Alonso for implementing the lightweight same-type constraint syntax for extensions on generic types which prompted us to think about this feature again for protocols.