Default Protocol Implementation Inheritance Behaviour - The current situation and what/if anything should be done about it

It's good to see this being discussed!

You're correct that when the superclass relies on the default implementation of the protocol requirement, Swift doesn't add vtable entries for dynamic dispatch, and uses static dispatch instead.

One possible solution to this would be to 'opt-in' to vtable entries and dynamic dispatch by marking the method as an override(invalid code today), while deprecating the old behavior or otherwise warning if override is not present and static dispatch will be used. It's unclear if we'd want to be able to silence the warning if someone really wanted to use static dispatch though. The result would be source compatible, but potentially confusing if we aren't able to warn about the current behavior, because forgetting to include override could introduce subtle bugs.

Edit: Another case that needs to be considered is an open class which uses a default implementation being subclassed from a different module.

I think the problem here is that subclasses don't get a protocol witness table, only the class declaring the conformance does. This is why the default implementation is called in those cases.

I don't know anything about the Swift compiler and I didn't read the whole post, but your third example is weird to me too.

My mental image is that methods in Swift are dynamically dispatched. If an instance has a particular method declared, then that method should be used, no matter its compile-time type. Maybe that image is wrong, but it seems consistent with your first two examples.

I tend to agree. I think this behavior is exposing an implementation detail (PWTs) in a way that is confusing for people who are used to the traditional OOP inheritance behaviors.

That being said, I would be a little worried changing this at this point since it's a behavior some are likely to have internalized.

3 Likes

While I see the value in that I would be a bit cautious of having effectively two different behaviours separated by a warning. You are correct though, it would have the nicety of having source compatibility, so it could be an option to keep in mind.

To be perfectly honest I hadn't even thought about how this would work when using other modules, this is a very valid point :+1:t2:

That makes sense. It also makes me slightly cautious that any change proposed will effect the ABI. Does anyone know if that the case?

You are right. This is why I think we should go beyond our surprise, and look for potential use cases of this behavior. Try to internalize it and see what it can give. Without this, we won't suite be able to classify this behavior as a bug that needs fixing, or a rare trick that fills a niche and maybe needs some warnings where it looks misused.

2 Likes

I wonder if it might be helpful for a conformer to be able to explicitly call a default implementation from the protocol it is conforming to. This would open up the possibility of superclasses opting in to dynamic dispatch by having a one-liner:

func methodWithDefaultImpl(args...) { protocol.methodWithDefaultImple(args) }

This way, existing code works as-is, but new code could offer dynamic dispatch to its subclasses.

1 Like

This is an interesting approach and one that would work with source compatibility. I actually would like to explore this as a stand alone feature outside of this particular topic as it might have other interesting applications, but I need to have a think about it a bit more.

But coming back to the issue at hand I'm wondering if this particular solution for this particular issue just opens up doors for mistakes. It feels like putting the responsibility of circumventing an issue in the hands of the user rather than trying to fix the issue itself. If we really want to leave the user to fix it then as it stands we can circumvent it by always having an implementation in the superclass and then we get the behaviour that we would expect (but probably more copy and pasted code).

I think the main problem is discoverability about this behaviour and making it obvious whats going on rather than the user being surprised at an unexpected behaviour.

I wonder if it might be helpful for a conformer to be able to explicitly call a default implementation from the protocol it is conforming to

I think that would be great! At the moment, there's a workaround (although ugly):

protocol P {
  func foo()
}

extension P {
  func foo() { print("Hello, world") }
}

class A: P {
  func foo() {
    struct Dummy: P {}
    Dummy().foo()
  }
}
1 Like

That workaround only helps for such functions that are pure. Or, perversely, only modify nonlocal state.

2 Likes

Yeah

Some related discussions (there is a lot more, though):



1 Like

There is a variant of our surprise. Now with existentials (the whatIsIt function below which calls whatAmI):

protocol P {
    func whatAmI()
}

extension P {
    func whatAmI() { print("protocol") }
}

class A: P {
}

class B: A {
    func whatAmI() { print("subclass") }
}

A().whatAmI()           // prints 'protocol'
B().whatAmI()           // prints 'subclass'
(B() as A).whatAmI()    // prints 'protocol'

func whatIsIt(_ p: P) {
    p.whatAmI()
}

whatIsIt(A())       // prints 'protocol'
whatIsIt(B())       // prints 'protocol' (????)
whatIsIt(B() as A)  // prints 'protocol'

It really looks like A acts as a "mask" which can't be lifted. The method is not virtual at all, and subclasses are unable to get back the status of regular protocol adopters.

1 Like

Thanks @sjavora. Does any of those threads talks about the "blocking super-class" confusion that is discussed here? It looked like a genuine new topic to me?

The following also prints protocol.

(B() as P).whatAmI()

I don't think either example is exhibiting new behavior. Anything treated as P, whether a concrete type such as a A, or the existential for P, will use the default implementation provided by P.

Yes :-) And this is QUITE unexpected, because:

Not at all, not when the method is a protocol requirement:

protocol P {
    // requirement
    func whatAmI()
}

extension P {
    func whatAmI() { print("protocol") }
}

class C: P {
    func whatAmI() { print("C") }
}

(C() as P).whatAmI()    // prints 'C', as expected

Protocol requirements are supposed to be always dynamically dispatched. Here our super class blocks this mechanism.

7 Likes

In line with this, what do you think about the following explanation?

Invocations of a method gained via conformance to a protocol with a default implementation will always invoke the default implementation. This applies to concrete instances of the conforming type, subtypes which are coerced to the conforming (super)type, and the existential for the protocol which contains the default implementation.

OK, I actually expect that, but only because I remember that protocol requirements present customization points. If the conforming type customizes the implementation, it will be used. The sticky point is when a subclass tries to customize, and finds that it isn't really doing so.

Yes, precisely. The contract "protocol requirement implies dynamic dispatch" is broken by the super class. So either the contract was inexistent (a wrong assumption), either there is a bug in the implementation of the contract.

2 Likes

I think that sums up this thread quite well.

Terms of Service

Privacy Policy

Cookie Policy