`type(of: s).foo()` gotcha

When running this fragment I see some unexpected (to me) result:

protocol P {}

extension P {
    static func foo() { print("P.foo") }
}
struct S: P {
    static func foo() { print("S.foo") }
}

func bar<T: P>(_ s: T) {
    T.foo()             // P.foo, expected
    S.foo()             // S.foo, expected
    print(T.self)       // S
    print(type(of: s))  // S
    type(of: s).foo()   // P.foo, why?!
}

bar(S())

I could fix it by making static func foo() a protocol requirement, but then the result of the first line T.foo() is different, and wonder is this supposed to be that way: type of s is evidently S but calling foo on the result prefers to call P.foo, so there's a difference in behaviour between calling S.foo() and type(of: s).foo().

3 Likes

Not adding foo to the requirements of P makes it a normal function without any kind of dynamic dispatch. Binding foo is done by looking at static information — the compiler decides at the call site which implementation gets called. The static type of type(of: s) is T: P, so P's foo is bound.

I think there's no way to dynamically decide which foo to call based on a type only known at runtime.

9 Likes

I asked a similar question a few years ago. See @Slava_Pestov's reply then: Calling type(of:) on an opaque function argument (some P) - #2 by Slava_Pestov

4 Likes

What would the generated code look like otherwise, if type(of: s).foo() could call either S.foo() or P.foo() at run time?

2 Likes

I don't quite understand... print(type(of: s)) outputs "S", the result of type(of: s) == S.self is true. Yet type(of: s).foo() and S.foo() are calling different foo's. Maybe that's how it should be ... but it's nowhere obvious.


E.g. refactoring from:

func callFoo<T: P>(_ s: T) {
    if type(of: s) == S.self {
        S.foo()
    } else if type(of: s) == D.self {
        D.foo()
    } else if type(of: s) == E.self {
        E.foo()
    } else {
        fatalError("TODO")
    }
}

to a seemingly equivalent:

func callFoo<T: P>(_ s: T) {
    type(of: s).foo()
}

breaks the app.

You have two distinct static methods both named foo()—in some contexts, one shadows but does not override the other. Swift allows you to do this sort of shadowing, but just because the two methods share the same name doesn't mean that you've given them a special relationship with each other. The behavior of your code would be no different if you refactor to give these distinct names.

Therefore, if you want to understand what's going on, simply give them distinct names such as foo1() and foo2() for clarity:

protocol P { }
extension P {
  static func foo1() { print("P.foo1") }
}
struct S: P {
  static func foo2() { print("S.foo2") }
}

Now, you can immediately understand the behavior observed: You literally cannot write type(of: s).foo2()—and for obvious reasons: if that were allowed to compile, what would be the behavior when you pass in an instance of some underlying type that has no static member foo2()?

11 Likes

Then perhaps this is the issue?

Does it help to see some static types?

let t: T.Type = T.self
let s: S.Type = S.self
let t2: T.Type = type(of: arg) // okay
let s2: S.Type = type(of: arg) // not okay, what if it’s not an S?
t2.foo() // what should this do?

It’s not really possible to ban the “shadowing” here, because all three parts are fine individually, and may be compiled separately (in separate modules). Different languages have different ways of dealing with it:

  • Objective-C doesn’t have method bodies on protocols, but message sends do indeed look up methods by name at run time, and methods in extensions (“categories”) can absolutely collide, and the run time just picks one. Which is good if you wanted that, bad if you didn’t.
  • Rust generally behaves like Swift here, but forces disambiguation more often. They still have concrete types (“inherent impls”) win over trait methods, though, so you might have a similar complaint there.
  • I don’t offhand know a language that requires everything to be compiled together so they can ban this type of thing, but that’s possible in some cases…but not Swift, where separate compilation and binary stability is an explicit design goal.
7 Likes

It really is the root of a lot of confusion. People expect type-based overloading to have runtime behaviors that do not exist, especially in conjunction with generics.

What it boils down to is that in this case, the call to f() inside g() always calls the second overload. There's no opportunity to dynamically dispatch between the two f()'s at runtime, based on the type of the T. And there's no way to do that at compile time either, because we don't implement generics by template expansion.

func f(_: Int) {}

func f<T>(_: T) {}

func g<T>(_ t: T) {
  f(t)
}

g(123) // always generic f()
f(123) // always the concrete f()

With type-based overloading, you lose principal typing, so the expression f(x) does not have a most general type that can be assigned to it independently of context; it can mean different things inside g() vs at the top level.

6 Likes

It seems like this should compile just fine, because the compiler should be able to statically reason that type(of: S.self) is an expression of type S.Type. But the signature for type(of:) is interesting:

func type<T, Metatype>(of: T) -> Metatype

Note the lack of any relationship between T and Metatype. You might expect to see where Metatype == T.Type here, which would enable the compiler to infer that type(of: S.self) is an expression of type S.Type. But that’s not what type(of:) does—it returns a value of S.Type.Type:

  > let s = type(of: S.self)
s: S.Type.Type = S.Type
(S.Type.Type)  = S.Type
1 Like

type(of:) is a special expression built-in to the type checker whose "real type" cannot be expressed in the language.

This is because with a concrete type T, the result is a T.Type, but with an any P, the result is an any (P.Type), not an (any P).Type as the substitution rule would otherwise imply. So I wouldn't try to read too much into func type's type signature, or lack thereof.

4 Likes

Right, this is what I think is actually key to the problem. type(of:) looks like a function that you could write in Swift, but it isn’t because it needs to behave in a subtly different way.

2 Likes

In the original example, type(of:) is applied to s, which is a value of type T, so the result is just T.Type. In this case, the special behavior of type(of:) does not play a role, and you'd get the same result with something like this too:

func myType<T>(of: T) -> T.Type { return T.self }

protocol P {
}

struct S: P {
  static func foo2() {}
}

func g<T: P>(_ s: T) {
  myType(of: s).foo2()  // no such member
}
3 Likes

You may have been misled by the suggestive variable name: s in OP’s example is not of type S but is of some type T: P.

5 Likes

So these are some "false friends" here that caused a confusion for me:

  • shadowing (specifically protocol extension's foo shadowing type's foo)
  • print(type(of: s)) revealing "S". Is there a way to get "T:P" printed somehow?
  • type(of: s) is S.Type and type(of: s) == S.self giving true
  • that == is doubly false friend here, as normally I could only compare things of the same kind, whilst comparing apples with oranges is normally a compilation error.

Explicit typing is a "true friend" here, thank you.

Are you talking about a situation when I first compile protocol P without foo in its extension, then compile struct S: P in a separate module (and not have a chance of getting any error/warning as at the moment func foo is unambiguous), then make a (breaking?) change (in the first module) to protocol P to now have foo in its extension? So until I recompile the second module I wouldn't have a chance to see any error / warning (were it emitted)? Is that different to a situation with other breaking changes?

Don’t forget the extension could be added in yet another module, and then a fourth module wants to use all three, for the most general case where it’s hard to blame anybody.

This is a type/value confusion, it’s like calling let x = 5; print(x) and expecting to get “Int”. “T: P” isn’t a value, it’s more like another parameter. The string “T” doesn’t exist anywhere at runtime, just like the string “s” doesn’t exist anywhere at runtime (not counting debug info).

The static types here are ==(_: Any.Type, _: Any.Type). Both sides get upcasted implicitly! Just like when you pass “5” to a function that takes Any.

3 Likes

Is that different to what we have with any other breaking change?

  • So now when this happens and some module adds foo to an extension of P my module/app is broken.
  • If we issue an error / warning – it's as broken, but slightly better overall, as at least I will be alerted when get to recompile my code.

Note, I'm calling print(type(of: s)), not print(s)...

It would help if it was, say, "Generic parameter at position 0, conforming P, revealed to be S", not necessarily mentioning "T" if that's not available during runtime.

1 Like

print isn’t special, it just prints its argument, which is the value “the struct S”. By the time it gets to print there’s nothing about where it came from. You can certainly say there should be a special form of print that does not behave like a plain old function, #dbg or something, but your mental model for print should be a plain old function.

3 Likes

But in the case where the shadowing happens in the same file or even module, we could at least issue a warning to the user informing them of the shadowing behavior and prompting them to make that declaration a protocol requirement.

Consider a trimmed down version of your example:

protocol P {
    // static func foo() // uncomment for dynamic dispatch
}

extension P {
    static func foo() { print("P.foo") }
}

struct S: P {
    static func foo() { print("S.foo") }
}

func bar<T: P>(_ x: T) {
    type(of: x).foo()    // P.foo, why?
}

But let’s consider a variation:

func baz<T>(_ x: T) {
    type(of: x).foo()    // obvious error: Type 'T' has no member 'foo'
}

That baz won’t compile is unremarkable: Without constraining T, the compiler cannot resolve this reference to foo.

So, go back to bar: The compiler accepts foo in that context solely because T conforms to P and therefore the foo reference resolves to P.foo. But, foo is statically dispatched, so there’s no way it would dynamically resolve that back to S.foo. (Obviously, using dynamic dispatch would change things.)

4 Likes