> FWIW they're marked as 'unlikely' here:
> https://github.com/apple/swift/blob/master/docs/GenericsManifesto.md#generic-protocols
>
> It would probably be useful to have counterarguments against the points
> raised in that document if you want to prepare a proposal.
Here's my counterargument.
* * *
Firstly, I think they're underestimating the feature's utility. Generic
protocols (real generic protocols, not Sequence<Element>) are already needed to
make several existing or likely future features work better. For instance:
* Pattern matching
Currently, if you want to customize your type's behavior in a `switch`
statement, you do it in an ad hoc, almost Objective-C-like way: You define a
free `~=` operator and the compiler resolves the overloads to magically find
and use it. There is no way to constrain a generic parameter to "only types
that can pattern match against type X", which seems like a pretty useful thing
to offer. For instance, in the past people have suggested some sort of
expression-based switch alternative. The lack of a pattern matching protocol
makes this impossible to implement in either the standard library or your own
code.
If we had generic protocols, we could define a protocol for this matching
operator and fix the issue:
protocol Matchable<MatchingValue> {
func ~= (pattern: Self, value: MatchingValue) -> Bool
}
protocol Equatable: Matchable<Self> {
func == (lhs: Self, rhs: Self) -> Bool
}
func ~= <T: Equatable>(lhs: T, rhs: T) -> Bool {
return lhs == rhs
}
extension Range: Equatable, Matchable<Bound> {}
func ~= <Bound: Comparable>(pattern: Range<Bound>, value: Bound) ->
Bool {
return pattern.lowerBound <= value && value < pattern.upperBound
}
Then you could write, for instance, a PatternDictionary which took patterns
instead of keys and, when subscripted, matched the key against each pattern
until it found a matching one, then returned the corresponding value.
* String interpolation
Currently, StringInterpolationConvertible only offers an
`init<T>(stringInterpolationSegment: T)` initializer. That means you absolutely
*must* permit any type to be interpolated into your type's string literals.
This blocks certain important use cases, like a `LocalizedString` type which
requires all strings it interacts with to pass through a localization API, from
being statically checked. It also would normally require any type-specific
behavior to be performed through runtime tests, but just as in `~=`, the Swift
compiler applies compile-time magic to escape this restriction—you can write an
`init(stringInterpolationSegment:)` with a concrete type, and that will be
preferred over the generic one.
In theory, it should be possible in current Swift to redefine
StringInterpolationConvertible to allow you to restrict the interpolatable
values by doing something like this:
protocol StringInterpolationConvertible {
associatedtype Interpolatable = Any
init(stringInterpolation: Self...)
init(stringInterpolationSegment expr: Interpolatable)
}
(This is no longer generic because I believe Interpolatable would have to be
somehow constrained to only protocol types to make that work. But you get the
idea.)
However, in many uses, developers will want to support interpolation of many
custom types which do not share a common supertype. For instance,
LocalizedString might want to support interpolation of any LocalizedString,
Date, Integer, or FloatingPoint number. However, since Integer and
FloatingPoint are protocols, you cannot use an extension to make them
retroactively conform to a common protocol with LocalizedString.
With generic protocols, we could define StringInterpolationConvertible like
this:
protocol StringInterpolationConvertible<Interpolatable> {
init(stringInterpolation: Self...)
init(stringInterpolationSegment expr: Interpolatable)
}
And then say:
extension LocalizedString:
StringInterpolationConvertible<LocalizedString>,
StringInterpolationConvertible<Integer>,
StringInterpolationConvertible<FloatingPoint> {
init(stringInterpolationSegment expr: LocalizedString) {
self.init()
self.components = expr.components
}
init(stringInterpolationSegment expr: Integer) {
self.init()
self.components.append(.integer(expr))
}
init(stringInterpolationSegment expr: FloatingPoint) {
self.components.append(.floatingPoint(expr))
}
init(stringInterpolation strings: LocalizedString...) {
self.init()
self.components = strings.map { $0.components
}.reduce([], combine: +)
}
}
This example shows an interesting wrinkle: A generic protocol may have
requirements which don't use any of the generic types, so that each of the
multiple conformances will require members with identical signatures. When this
happens, Swift must only allow the member to be implemented once, with that
implementation being shared among all conformances.
* Subtype-supertype relationships
Though not currently implemented, there are long-term plans to permit at least
value types to form subtype-supertype relationships with each other. A protocol
would be a sensible way to express this behavior:
protocol Upcastable {
associatedtype Supertype
init?(attemptingCastFrom value: Supertype)
func casting() -> Supertype
}
However, this would require a type to have only one supertype, which isn't
necessarily appropriate. For instance, we might want a UInt8 to be a subtype of
both Int16 and UInt16. For that to work, Upcastable would have to be generic:
protocol Upcastable<Supertype> {
init?(attemptingCastFrom value: Supertype)
func casting() -> Supertype
}
extension UInt8: Upcastable<Int16>, Upcastable<UInt16> { … }
Without generic protocols, the only way to offer sufficiently flexible
subtyping is to offer it as a one-off, ad-hoc feature with special syntax.
* * *
Secondly, I think the concerns about people trying to use Sequence as a generic
protocol aren't that big a deal. To put it simply: Sequence is *not* a generic
protocol. The Swift team controls the definition of Sequence, and we define it
to not be generic. If people complain, we explain that generic protocols don't
actually do the right thing for this and that they should use existentials
instead. We put it in a FAQ. It's just not that big a deal.
The real concern is not that people will try to use Sequence as a generic
protocol, but that they will try to inappropriately make their own protocols
generic. I see this as a more minor issue, but if we're worried about it, we
can address it by changing the mental model to one which doesn't make it look
like a generics feature.
Basically, rather than thinking of this feature as "generic protocols", it
could instead be thought of as "associated type overloading": a particular
associated type can be overloaded, and you can use a `where` clause to select a
particular overload. This would have a different syntax but handle the same use
cases.
For instance, rather than saying this:
protocol Matchable<MatchingValue> {
func ~= (pattern: Self, value: MatchingValue) -> Bool
}
protocol Equatable: Matchable<Self> {
func == (lhs: Self, rhs: Self) -> Bool
}
func ~= <T: Equatable>(lhs: T, rhs: T) -> Bool {
return lhs == rhs
}
extension Range: Equatable, Matchable<Bound> {}
func ~= <Bound: Comparable>(pattern: Range<Bound>, value: Bound) ->
Bool {
return pattern.lowerBound <= value && value < pattern.upperBound
}
struct PatternDictionary<Matching, Value>: DictionaryLiteralConvertible
{
typealias Key = Matchable<Matching>
typealias Value = OutValue
var patterns: DictionaryLiteral<Key, Value>
init(dictionaryLiteral pairs: (Key, Value)...) { patterns =
DictionaryLiteral(pairs) }
subscript(matchingValue: Matching) -> Value? {
for (pattern, value) in patterns {
if pattern ~= matchingValue {
return value
}
}
return nil
}
}
You could instead say:
protocol Matchable {
@overloadable associatedtype MatchingValue
func ~= (pattern: Self, value: MatchingValue) -> Bool
}
protocol Equatable: Matchable where MatchingValue |= Self {
func == (lhs: Self, rhs: Self) -> Bool
}
func ~= <T: Equatable>(lhs: T, rhs: T) -> Bool {
return lhs == rhs
}
extension Range: Equatable, Matchable {
typealias MatchingValue |= Bound
}
func ~= <Bound: Comparable>(pattern: Range<Bound>, value: Bound) ->
Bool {
return pattern.lowerBound <= value && value < pattern.upperBound
}
struct PatternDictionary<Matching, Value>: DictionaryLiteralConvertible
{
typealias Key = Any<Matchable where .MatchingValue & Matching>
typealias Value = Value
var patterns: DictionaryLiteral<Key, Value>
init(dictionaryLiteral pairs: (Key, Value)...) { patterns =
DictionaryLiteral(pairs) }
subscript(matchingValue: Matching) -> Value? {
for (pattern, value) in patterns {
if pattern ~= matchingValue {
return value
}
}
return nil
}
}
(Is `MatchingValue |= Bound` a union type feature? I'm not sure. It does have
the syntax of one, but there's a separate overload for each type, so I don't
think it really acts like one.)
This is very nearly the same feature, but presented with different
syntax—effectively with a different metaphor. That should prevent it from being
abused the way the core team fears it will be.
(One difference is that this version permits "vacuous" conformances: in theory,
there's no reason you couldn't conform to a protocol with an `@overloadable
associatedtype` and define zero types. On the other hand, that's not
necessarily *wrong*, and might even be useful in some cases.)
--
Brent Royal-Gordon
Architechies
_______________________________________________
swift-evolution mailing list
[email protected]
https://lists.swift.org/mailman/listinfo/swift-evolution