Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SR-10482] Incorrect method dispatch with multiple (overlapping) constrained protocol extensions when invoked on subclass of conforming type inside generic function #52882

Closed
swift-ci opened this issue Apr 14, 2019 · 6 comments
Labels
bug A deviation from expected or documented behavior. Also: expected but undesirable behavior. compiler The Swift compiler in itself

Comments

@swift-ci
Copy link
Collaborator

Previous ID SR-10482
Radar rdar://problem/49880954
Original Reporter pacheco (JIRA User)
Type Bug
Status Resolved
Resolution Invalid
Environment

Xcode 10.2 (10E125)

Swift 5.0

Additional Detail from JIRA
Votes 0
Component/s Compiler
Labels Bug
Assignee None
Priority Medium

md5: d57d18b02885ceee81d5fad3e7a85fe6

is duplicated by:

  • SR-10615 Delegation From Protocol Extension With Base Class Calls Incorrect Method

Issue Description:

Sorry if my description is not the most accurate, but I really tried 🙂

From my understanding, when a protocol has multiple (overlapping) constrained protocol extensions for the same method, partial ordering is used based on the constraints, and the most specialized one is picked.

However, this doesn't seem to be happening when passing a subclass of a conforming type to a generic function, when the subclass specifies a conformance that should cause the most specialized extension to be used.

Below you can find a reduced example which demonstrates the issue:

// protocol structure

protocol A {
    associatedtype T
}

protocol B: A {
    var value: T { get }
}

protocol C: A {
    var modify: (T) -> T { get }
}

// protocol with multiple (overlapping) extensions

protocol D: A {
    func makeValue() -> T
}

extension D where Self: B {
    func makeValue() -> T { return value }
}

extension D where Self: B & C {
    func makeValue() -> T { return modify(value) }
}

// conforming types

class SomeClass: D & B {
    typealias T = String

    var value: T { return "SomeClass" }
}

class SubClass: SomeClass & C {

    override var value: T { return "SubClass" }
    var modify: (String) -> String = { "modified \($0)" }
}

class AnotherClass: D & B & C {
    typealias T = String

    var value: String { return "AnotherClass" }
    var modify: (String) -> String = { "modified + \($0)" }
}

struct SomeStruct: D & B & C {
    typealias T = String

    var value: String { return "SomeStruct" }
    var modify: (String) -> String = { "modified(\($0))"}
}

// Demo

// invoking method on concrete types has expected behaviour:

SomeClass().makeValue() // returns "SomeClass"
SubClass().makeValue() // returns "modified SubClass"
AnotherClass().makeValue() // returns "modified + AnotherClass"
SomeStruct().makeValue() // returns "modified(SomeStruct)

// invoking method on generic function doesn't have expected behaviour on specialized subclass of conforming type:

func invokeMakeValue<F: D>(on foo: F) -> F.T { return foo.makeValue() }

invokeMakeValue(on: SomeClass()) // returns "SomeClass"
invokeMakeValue(on: SubClass()) // returns "SubClass" <---- invokes incorrect protocol extension
invokeMakeValue(on: AnotherClass()) // returns "modified + AnotherClass"
invokeMakeValue(on: SomeStruct()) // returns "modified(SomeStruct)
@belkadan
Copy link
Contributor

A generic function only gets one implementation (unlike, say, a C++ template), which is then used no matter argument you pass to it. That means it has to call the most general implementation of your properties. The only ways to get type-based dispatch in Swift are (1) classes and overriding, (2) calling a protocol requirement, or (3) explicitly checking types with as?.

@swift-ci
Copy link
Collaborator Author

Comment by André Pacheco Neves (JIRA)

Thanks for the quick reply!

I see... So in this particular case I would be forced to override the subclass' method to have the behavior I expect (1)? Because I think I am already using (2), but there are two competing implementations of the same requirement. Explicitly checking with `as?` is not very ideal too, as it doesn't "scale" very well.

Even in the override scenario It's really awkward to not be able to have the "free" implementation given by the extension.

Don't you think it's weird for users to have two distinct behaviors to what intuitively should be the same one? Shouldn't the compiler also infer the most constrained implementation when "choosing" which one to use?

@belkadan
Copy link
Contributor

It's tricky here because the compiler has to choose how to satisfy a protocol requirement at the time you declare the conformance. That's a property of SomeClass that isn't revisited for SubClass. You're right that the compiler could revisit this decision at that point, but that could lead to strange behavior if SomeClass and SubClass are in different libraries. (SomeClass adds a conformance to D, but SubClass isn't recompiled, so it gets the same behavior as the base class. If SubClass is later recompiled, that behavior will change.)

@swift-ci
Copy link
Collaborator Author

Comment by André Pacheco Neves (JIRA)

It's tricky here because the compiler has to choose how to satisfy a protocol requirement at the time you declare the conformance

Why isn't it the same for both scenarios, since the conformance is clearly declared and it works as "expected" when invoked directly on a concrete type? I don't get why the generic "resolution" doesn't follow the same decision flow as when invoked directly. Doesn't the compiler have the same information available? It appears as if the compiler only checks the first node on the hierarchy (the super class), but stops there when evaluating the implementation to use.

You're right that the compiler could revisit this decision at that point, but that could lead to strange behavior if SomeClass and SubClass are in different libraries. (SomeClass adds a conformance to D, but SubClass isn't recompiled, so it gets the same behavior as the base class. If SubClass is later recompiled, that behavior will change.)

My compiler knowledge is obviously limited (and sorry for that 😛), but in the above case, how does it behave if we simply invoke makeValue directly on an instance? Does it not face the same challenge?

Sorry for being persistent, but I am struggling to understand why this is not an issue. It honestly seems to me that this (unexpected) behavior will inevitably lead to situations where developers (like me) are caught by surprise when they discover which implementation has actually been chosen by the compiler (which is both unintuitive and poorly documented). As a language user I expect the compiler to choose how to satisfy a protocol requirement in a "consistent" way, unless explicitly instructed otherwise (e.g forcing as?), which isn't happening.

Thank you for your patience 🙂

@belkadan
Copy link
Contributor

Asking questions is always good!

The rule effectively is the same: choose what to use at the point you use it. In your example, the difference is the point of use:

1. class SomeClass: D & B is a point-of-use for "anything needed for SomeClass to conform to D" and "anything needed for SomeClass to conform to B", so we pick the best value and makeValue available to SomeClass. That's going to be its own value (which may be overridden), and the D where Self: B version of makeValue. This gets recorded in "how SomeClass conforms to D" and "B".

2. class SubClass: SomeClass & C is a point-of-use for "anything needed for SubClass to conform to C", so we pick the best modify available to SubClass. That's its own modify (there aren't any others). This gets recorded in "how SubClass conforms to C".

3. class AnotherClass: D & B & C is a point-of-use for "anything needed for AnotherClass to conform to D" and "B" and "C", so we pick the best value, modify, and makeValue available to AnotherClass. That's its own value and modify and the D where Self: B & C version of makeValue.

4. struct SomeStruct: D & B & C is the same as AnotherClass, really.

Okay, that's it for the conformances. Now we're at the concrete calls.

5. SomeClass().makeValue() is a point-of-use for "find a makeValue on SomeClass", and so it looks for the best makeValue available to SomeClass. This is the same decision made in (1), so we get the same answer: the D where Self: B version of makeValue.

6. SubClass().makeValue(), AnotherClass().makeValue(), and SomeStruct().makeValue() all work the same way: they have exactly as much information as 2-4, and so they pick the same overload of makeValue.

7. foo.makeValue() inside invokeMakeValue<F: D> is also a point-of-use, but not exactly an interesting one. All we know about foo is that its type conforms to D, and so the only choice is to call the requirement in the protocol…however that's been implemented.

8. invokeMakeValue(on: SomeClass()) is not a point-of-use for makeValue. Instead, it asks "how does SomeClass conform to D?" and passes that information to invokeMakeValue. The answer is what we picked in (1).

9. Similarly, invokeMakeValue(on: SubClass()) is also not a point-of-use for makeValue; it asks "how does SubClass conform to D?" The answer is "via its superclass, SomeClass", and therefore we get the same behavior as in (1). But with the overridden value.

10. invokeMakeValue(on: AnotherClass()) and invokeMakeValue(on: SomeStruct()) give us the same answers as (3) and (4), because they both conform to D directly.

Whew.

So, what would it take to change this behavior? It would have to be at step 2: when subclassing SomeClass, recheck the conformance to D and realize that there's now a "better" overload of makeValue() available. If D is a private protocol, rechecking that conformance for SubClass may not even be possible.

I hope this is a suitable explanation, if not necessarily as satisfying as you might like!

@swift-ci
Copy link
Collaborator Author

Comment by André Pacheco Neves (JIRA)

Wow, thanks for the great explanation @belkadan, It's now much clearer in my head (and I will certainly come back here if I hit this again 😃).

You're right it's not the outcome I would've hoped for, but I now understand better why this is working as intended. Who knows, maybe in the future that extra recheck step is added to subclasses and this stops happening? 😛

Thanks once again, and please keep being awesome!

@swift-ci swift-ci transferred this issue from apple/swift-issues Apr 25, 2022
This issue was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug A deviation from expected or documented behavior. Also: expected but undesirable behavior. compiler The Swift compiler in itself
Projects
None yet
Development

No branches or pull requests

2 participants