Calling a protocol with associatedType's method, doesn't always pick the same specialized generic implementation

In swift 5.7, the compiler will not always pick the same default implementation for protocol methods that are present in protocol definition (which ideally should be dynamically dispatched) with generic specialization.

In the following example, ExplicitFoo and GenericFoo<Int> are both of type any Foo<Int>. Hence when calling .baz() the compiler will pick the specialized implementation. But when they're erased via AnyFoo (or any Foo or any Foo<Int>), the compiler will pick the default non-specialized implementation for GenericFoo<Int>, but won't do the same for ExplicitFoo. Which IMHO makes it inconsistent and ambiguous.

Can anyone explain if it is the expected behavior and why? Since I thought about it and couldn't come-up with any explanations, I went ahead and reported a bug.

protocol Foo<Bar> {
    associatedtype Bar
    func baz() -> String
}

extension Foo {
    func baz() -> String { "Foo" }
}

extension Foo<Int> {
    func baz() -> String { "Foo<Int>" }
}

struct AnyFoo<Bar>: Foo {
    private let _baz: () -> String
    func baz() -> String { _baz() }
    init<F: Foo>(foo: F) where F.Bar == Bar {
        self._baz = { foo.baz() }
    }
}

struct ExplicitFoo: Foo { typealias Bar = Int }
struct GenericFoo<Bar>: Foo { }

And then in usage:


let explicit = ExplicitFoo()
let generic = GenericFoo<Int>()

assert(explicit.baz() == "Foo<Int>")
assert(generic.baz() == "Foo<Int>")

let erasedExplicit = AnyFoo(foo: explicit)
let erasedGeneric = AnyFoo(foo: generic)

assert(erasedExplicit.baz() == "Foo<Int>")
assert(erasedGeneric.baz() == "Foo<Int>") // Fails since `erasedGeneric.baz()` returns "Foo"

It even fails with any form of type-erasure:

  1. Using non-generic type-erased AnyFoo
struct AnyFoo: Foo {
    typealias Bar = Never
    
    private let _baz: () -> String
    func baz() -> String { _baz() }
    
    init<F: Foo>(foo: F) {
        self._baz = { foo.baz() }
    }
}
  1. Abstracting instances as any Foo<Int>
let erasedExplicit: any Foo<Int> = explicit
let erasedGeneric: any Foo<Int> = generic
  1. Abstracting instances as any Foo
let erasedExplicit: any Foo = explicit
let erasedGeneric: any Foo = generic

in all 4 approaches, the result is the same. The compiler will choose a different implementation for erasedExplicit vs erasedGeneric.

Environment

  • Swift compiler version info
    swift-driver version: 1.62.15 Apple Swift version 5.7.2 (swiftlang-5.7.2.135.5 clang-1400.0.29.51)
  • Xcode version info
    Version 14.2 (14C18)
  • Deployment target:
    Target: arm64-apple-macosx13.0
1 Like

This understanding (specifically, "Hence") is incorrect. The "most specific" implementation of baz() that is called when using a value of concrete type, whether ExplicitFoo or GenericFoo<Int>, is determined without regard to whether any implementation satisfies a protocol conformance.

In Swift, a type can conform to a protocol in exactly one way; a generic type such as GenericFoo cannot conform to a protocol in different ways depending on its generic parameter(s).

For ExplicitFoo, the "most specific" implementation of baz() which satisfies the protocol requirement is this one:

extension Foo<Int> {
    func baz() -> String { "Foo<Int>" } /* (b) */
}

By contrast, for GenericFoo, the "most specific" implementation of baz() which satisfies the protocol requirement is this one:

extension Foo {
    func baz() -> String { "Foo" } /* (a) */
}

When you are using a value of concrete type GenericFoo<Int>, then there is an additional implementation of baz() given by (b) which shadows but does not override the implementation that satisfies the protocol requirement, which is still (a). This goes back to the principle that there is one and only one way in which GenericFoo conforms to Foo.

When not using the concrete type, you are explicitly requiring the compiler to dynamically dispatch to the implementation of baz() which satisfies the protocol requirement. For any value of type GenericFoo, that would be implementation (a).

2 Likes

This was exactly what I was missing in my deductions. Now it makes sense that the method .baz() was shadowed and not overridden.

@xwu thank you for your explanations :pray:

This tripped me up, so I had to write specific implementations by forcing generic conformance in the function definition.

Code starts to fall apart when you begin mixing generics across functions and parameters which are protocol types.