Introduction
Swift’s generics system is highly expressive, but understanding the full generality of protocols with associated types, generic signatures with where clauses, and other generics features is a significant barrier to introducing generics into a Swift project. A major goal of a more approachable generics system is easing the learning curve of abstracting a concrete API into a generic one by improving the ergonomics of writing generic code in Swift. This discussion is to solicit feedback on possible directions toward achieving this goal, and gather other ideas surfaced by the community. Questions, comments, and ideas are all welcome!
Many of the ideas in this post were laid out by @Joe_Groff in Improving the UI of generics.
The problem
Without a deep understanding of existential types and generics, existential types appear to be the most natural solution when abstracting away concrete types. The confusion between existential types versus generics is a long-standing problem for Swift programmers. Joe shed a lot of clarity on the difference between these two concepts in his write-up, but the language, tools, and documentation still make it far too easy for programmers to reach for existentials when a better solution is to use generics. And worse — when programmers hit the fundamental limitations of value-level abstraction, they often don’t realize that abstracting at the type level is the right path forward. Finally, once programmers do realize that they need to introduce generics, there’s a giant learning curve of understanding angle-bracket syntax, associated types, where clauses, type constraints, and more, before they can make further progress on whatever they were trying to accomplish in the first place. These poor ergonomics of introducing generics are a significant productivity barrier for programmers, and the steep learning curve combined with the lack of guidance frame the generics system as a tool only for the highly experienced Swift guru.
What follows is a set of possible directions for the language and tools to make Swift’s existing generics system more ergonomic by designing more progressive disclosure and guidance into the generic programming experience. Note that the goal is not to add more expressive power to the generics system.
Possible directions
Type parameter inference via some
Part of the reason why programmers reach for existential types over type parameters is because existential types have a much more natural syntax. To bridge the syntactic complexity jump to using generics, we could introduce type parameter inference from certain declarations that use opaque some
types.
Parameter declarations
Parameter declarations with opaque types could be sugar for a type parameter on the enclosing function that conforms to the specified protocol. For example:
func concatenate(a: some Collection, b: some Collection) -> some Collection { ... }
Using some Collection
as a parameter type would be sugar for a type parameter conforming to Collection
:
func concatenate<C1: Collection, C2: Collection>(a: C1, b: C2) -> some Collection { ... }
This feature combined with light-weight same-type constraint syntax would allow type parameters and their constraints to be expressed directly on the relevant parameters, instead of separating semantic information about the parameter from the parameter itself, making generic function signatures easier to comprehend. For example, the following function signature:
func maxValue<C: Collection>(in collection: C) -> C.Element where C.Element == Int
could be simplified to:
func maxValue(in collection: some Collection<Int>) -> Int
Stored property declarations
Similarly, using the some
modifier on stored property types could allow type parameter inference on the enclosing type:
protocol Shape { ... }
struct Button {
var shape: some Shape
}
// sugar for
struct Button<S: Shape> {
var shape: S
}
To make the generic signature more explicit for clarity, we could require programmers to write explicit placeholder syntax, such as <_>
, on the enclosing declaration to signal that type parameters are being inferred from the properties inside the body, e.g.
struct Button<_> {
var shape: some Shape
}
Type parameter inference from stored properties is an interesting direction, but there are some significant downsides that are important to highlight:
- This feature is a source breaking change; a stored property with an opaque
some
type is valid Swift code today, but the concrete type is inferred from the stored property’s initial value specified via=
, rather than inferring a generic argument at the use site of the enclosing type. - This would introduce a subtle difference between
var shape: some Shape
andvar shape: some Shape { ... }
. Refactoring a stored property into a computed one (and vice versa) is a common action, and this feature would introduce a type system difference between stored and computed properties. - This feature raises challenging questions about what it means to spell the enclosing type name in other contexts, including:
- Can you explicitly specify generic arguments for the implicit generic parameters in angle brackets? If so, is the order dependent on declaration order of the stored properties, or is there some other canonical order? In either case, the programmer cannot simply look at the declaration of the type to see what must be specified.
- If you cannot explicitly specify generic arguments, they necessarily need to be inferred. In that case, what does it mean to write the plain type name? How can you use such a type as, say, a parameter type?
- This feature is significantly limited to simple cases where you do not need to use the property type anywhere else in the struct. As soon as you need to use the type elsewhere, you either need to name the type parameter as usual, or you need a new syntax for referring to the type of the stored property. In either case, this is exposing more of the learning curve to the programmer pretty quickly, which may render this potential feature to be a shallow step toward easing the learning curve.
Though type parameter inference via some
would give generics a more light-weight syntax, programmers will still reach for existential types first, and they may struggle to understand what some
means in different contexts and how it’s different from writing the plain protocol name. One possible solution is to allow programmers to elide the some
keyword in certain cases, such as when using the sugared same-type constraint syntax, e.g. Collection<Int>
. However, this may introduce more confusion between existential types and generics, especially given the acceptance of SE-0309: Unlock Existentials for All Protocols. As long as existential types have a more natural spelling, any syntax for type parameter inference would not alleviate the need for more informative and easily accessible guidance toward generics.
Same-type constraint inference via default arguments
When abstracting the concrete type of a stored property, it’s common to start with a property that has an initial value. For example:
protocol Shape { ... }
struct Circle: Shape { ... }
struct Button {
var shape = Circle()
}
If a programmer wants Button
to work with any shape type, and they want the default shape to still be Circle()
, they might try to write:
struct Button<ShapeType: Shape> {
var shape: ShapeType = Circle()
}
For which the compiler produces the following error:
error: cannot convert value of type 'Circle' to specified type 'ShapeType'
var shape: ShapeType = Circle()
^~~~~~~~
as! ShapeType
Upon this error, the programmer might try other ways of providing a default argument, such as writing an explicit initializer with a parameter of type Circle
. None of the diagnostics surfaced in this process will lead the programmer to the right solution, which is to write the default argument in an initializer with a same-type constraint where ShapeType == Circle
. In order to keep the synthesized member-wise initializer that can be called for any ShapeType
, this initializer must be written in an extension:
extension NoteButtonStyle where ShapeType == Circle {
init(circle: Circle = Circle()) {
self.init(shape: circle)
}
}
This code is using a same-type constraint to default ShapeType
to Circle
when the default argument Circle()
is used. We can alleviate this boilerplate, and (more importantly) the depth of understanding required to write it, by allowing default arguments to be more concrete than the parameter type, implying a same-type constraint when that default argument is used:
struct NoteButtonStyle<ShapeType: Shape> {
var shape: ShapeType
// Implies that `ShapeType == Circle` when `shape`
// is omitted at the call-site.
init(shape: ShapeType = Circle()) { ... }
}
This not only decreases the cognitive overload for the programmer, but it also improves compile-time performance by decreasing the number of overloads required to achieve a default argument with a concrete type.
Guide programmers toward type parameterization with tooling
The directions above make generics easier to write, but the generic programming experience is still missing guidance toward using generics in the first place. For programmers with experience in languages where the primary tool for polymorphic behavior is subtype polymorphism, guidance toward parametric polymorphism with value types is crucial for making Swift easier to learn.
Diagnostics
It’s possible to guide the programmer toward type parameterization using diagnostic fix-its. For example, if the user reaches for an existential type in a way that doesn’t work:
protocol P {}
extension P {
func method(_: Self) {}
}
func useMethod(p: P) {
p.method // error: member 'method' cannot be used on value of protocol type 'P'; use a generic constraint instead
}
The error message attached to p.method
can include a fix-it to insert a type parameter conforming to P
, and change the type of p
to be that type parameter, transforming the useMethod
function into:
func useMethod<PType: P>(p: PType) {
p.method // 👍
}
Furthermore, error messages about existential types could have educational notes to explain when the programmer might want to use generics instead of existential types.
This strategy suffers from allowing the user to go down the wrong path for some amount of time before they discover that they should use a type parameter instead of an existential type. Any expressive power added to existential types exacerbates this issue.
Code completion
One possible way to guide the user toward type parameterization before going down the existential type route is via code completion when the user types a protocol name. Imagine the programmer is adding a type annotation for a stored property, and they start to type Collection
:
struct Document {
var lines: Collect|
}
Code completion could suggest a completion to insert a type parameter <CollectionType: Collection>
on the enclosing struct, and complete the property type with CollectionType
.
Refactoring actions
We can also help programmers with compiler-aided source transformations via refactoring actions. For a given declaration with concrete type, one could imagine a refactoring action to abstract away that concrete type with a type parameter, perhaps with an option to constrain that type parameter to any of the protocols that the concrete type conforms to.
Other ideas?
If you have other ideas for how to make Swift’s generics system more approachable, please leave a comment below with your thoughts!