[swift-evolution] Making protocol conformance inheritance controllable
Joe Groff
jgroff at apple.com
Tue Dec 15 16:22:02 CST 2015
> On Dec 15, 2015, at 10:26 AM, Thorsten Seitz <tseitz42 at icloud.com> wrote:
>
>
>> On Dec 10, 2015, at 8:04 PM, Joe Groff via swift-evolution <swift-evolution at swift.org <mailto:swift-evolution at swift.org>> wrote:
>>
>> I've had a number of twitter conversations with users who have valiantly fought our type system and lost when trying to make their protocols interact well with non-final classes. A few from recent memory:
>>
>> - Rob Napier struggling to implement a `copy` method that works well with subclasses: https://twitter.com/cocoaphony/status/660914612850843648 <https://twitter.com/cocoaphony/status/660914612850843648>
>> and making the observation that the interaction of protocol conformance and subtyping is very difficult to teach.
>>
>> - Matt Bischoff trying to make Cocoa class clusters retroactively conform to his factory protocol:
>> https://twitter.com/anandabits/status/664294382774849536 <https://twitter.com/anandabits/status/664294382774849536>
>>
>> and Karl Adam trying to do the same:
>> https://gist.github.com/thekarladam/c3094769cc8c87bf55e3 <https://gist.github.com/thekarladam/c3094769cc8c87bf55e3>
>>
>> These problems stem from the way protocol conformances currently interact with class inheritance—specifically, that if a class conforms to a protocol, then all of its possible derived classes also conform. This seems like the obvious way things should be, but in practice it ends up fighting how many classes are intended to be used. Often only a base class is intended to be the public interface, and derived classes are only implementation details—Cocoa class clusters are a great example of this. The inheritance of protocol conformances also imposes a bunch of knock-on complexity on conforming classes—initializer requirements must be satisfied by `required` initializers (which then must be overridden in all derived classes, to the pain of anyone touching an NSCoding-inherited class), and methods often must return dynamic `Self` when they'd really prefer to return the base class.
>>
>> To mitigate these issues, I'd like to float the idea that protocol conformances *not be* inherited by default. If you declare a class as conforming to a protocol, only exactly that class can be bound to a type parameter constrained by that protocol:
>
> What exactly does that mean?
>
> If A conforms to Runcible and B is derived from A that means that B is a subtype of A and can be used everywhere an A can be used (substitution principle).
> Therefore if I can use an A where a Runcible is required, I must be able to use a B as well. Subtyping is transitive which gets somehow broken in this proposal (or maybe not, see below).
B-is-subtype-of-A-is-subtype-of-Runcible still holds, but B couldn't be bound to a type variable that was constrained to be Runcible, only A. Subtyping is never quite cut and dry when interacting with parametric type systems; it's not always the case that Foo<B> is a subtype of Foo<A> even if B is a subtype of A. This adds extra edge cases, definitely.
>
>>
>> protocol Runcible {}
>> class A: Runcible { }
>> class B : A { }
>
>
>> func foo<T: Runcible>(x: T) {}
>>
>> foo(B()) // calls foo with T == A
>
> This would just hold for the static type of T, right?
> The dynamic type of x still stays B and dynamic dispatch would respect that whereas static dispatch (e.g. for extension methods declared for A and B separately) would use the static type A (i.e. dispatch to the extension method declared for A).
Right, the dynamic type of the variable 'x' would still be B, even though T == A.
>
>> Since subclasses are still subtypes of the base class, in many cases client code won't have to change at all, since derived instances can implicitly upconvert to their conforming base class when used in protocol types or generics that only the base class conforms to. (There are cases like if the type parameter appears in a NonCovariant<T> type where this isn't possible, though.)
>
>
>> Protocol requirements for a non-inherited conformance don't need to be `required` initializers, or maintain covariant returns:
>
> The required initializers are probably the motivation for this change.
> Is there a problem with covariant returns as well? I'd think a subclass does not have to do anything to just get them, so I don't see a problem there.
>
> If I understand the desire behind the proposal correctly, it is about
> (a) stopping Self types to vary below some point in the hierarchy
> (b) controlling the requiredness of initializers within the hierarchy (doesn’t that collide with the idea of requiring an initializer in the first place?)
>
> I have the vague felling (or hope) that this can be done more nicely in other ways without tampering with subtyping.
>
> Joe Groff recently proposed in another thread something similar for Self types in Equatable:
>
> public protocol Equatable {
> typealias EquatesWith = Self where Self: EquatesWith
> func ==(lhs: EquatesWith, rhs: EquatesWith) -> Bool
> }
>
> This way subclasses or subprotocols can decide where they want to fix EquatesWith to a fixed value:
>
> protocol Fungible {
> typealias F = Self where Self: F
> static func funged() -> F
> }
>
> class C: Fungible {
> class func funged() -> F { return C() }
> }
>
> class D: C {
> override class func funged() -> F { return D() }
> }
>
> class X: C {
> typealias F = C
> }
>
> D.funged() has static type D
> X.funged() has static type C
>
>
> Would this fit your expectations for the Self type issues? Or have I misunderstood the problem completely?
Equatable has the opposite problem that its Self requirements are contravariant, since they appear in argument position; this means Equatable itself can't naturally be used as the existential type of all Equatables, but it isn't a problem for a class hierarchy to conform all subclasses to Equatable—`Base == Base` satisfies the protocol requirement for any possible Derived type. An associated type indirection like this still wouldn't solve the factory method covariance problem, since `Self` inside protocol conformances today always refers to the potentially-derived type when classes are involved.
-Joe
>
> Maybe something similar can be done for requiredness of initializers.
>
> -Thorsten
>
>
>> protocol Fungible {
>> init()
>> static func funged() -> Self
>> }
>>
>> class C: Fungible {
>> init() {} // Non-required init is fine, since subclasses aren't directly Fungible
>>
>> // Non-Self return is fine too
>> class func funged() -> C { return C() }
>> }
>>
>> An individual subclass that wanted to refine the conformance could do so by `override`-ing it, and providing any necessary covariant overrides of initializers and methods:
>>
>> class D: C, override Fungible {
>> // D must provide its own init()
>> init() { super.init() }
>>
>> // D must override funged() to return D instead of C
>> override class func funged() -> D { return D() }
>> }
>>
>> And if a class hierarchy really wants to impose a conformance on all possible subclasses, as happens today, we could let you opt in to that:
>>
>> class E: required Fungible {
>> // init() must be required of all subclasses
>> required init() { }
>>
>> // funged() must return a covariant object
>> class func funged() -> Self { return Self() }
>> }
>>
>> This is undoubtedly a complication of the language, but I think it might let us more accurately model a lot of things people seem to want to do in practice with class hierarchies and protocols, and it simplifies the behavior of the arguably common case where inheritance of the conformance isn't desired. What do you all think?
>>
>> -Joe
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.swift.org/pipermail/swift-evolution/attachments/20151215/a2f6f7ad/attachment.html>
More information about the swift-evolution
mailing list