I just spent a lot of time debugging what came down to the following:
struct Foo{T<:Integer} end
f(::Type{F}) where {T, F<:Foo{T}} = 1
f(::Type{F}) where {F<:Foo} = 2
I expected f(Foo{Int}) to return 1 and f(Foo) to return 2, yet
julia> f(Foo{Int})
2
julia> f(Foo)
2
If you remove the <:Integer constraint from the definition of Foo, f(Foo{Int}) returns 1 as expected. Alternatively, replacing the first method of f with
f(::Type{F}) where {T<:Integer, F<:Foo{T}} = 1
also works as I expected.
Is this behavior desired? It was certainly surprising to me.
julia> struct Foo{T<:Integer} end
julia> Foo <: (Foo{T} where T)
true
julia> (Foo{T} where T) <: Foo
false
julia> Foo <: (Foo{T} where T <: Integer)
true
julia> (Foo{T} where T <: Integer) <: Foo
true
julia> struct Foo{T} end
julia> Foo <: (Foo{T} where T)
true
julia> (Foo{T} where T) <: Foo
true
May I ask why is this expected? I think if you remove <:Integer, the 2 functions become semantically indistinguishable, not sure why Julia goes for one over the other, perhaps some subtle rule in the code?
I mostly expected this from previous experience. Unfortunately, the documentation on method specificity could be better, Wishlist: document method specificity rules ¡ Issue #23740 ¡ JuliaLang/julia ¡ GitHub. I do think itâs desired to have this behavior, since it can be quite useful to distinguish between f(Foo) and f(Foo{SomeT}) for all SomeT without having to define a method for each possible SomeT: If f is called with e.g., Foo{String} or Foo{Int} as the argument, the user has supplied more information than when f is called with just Foo as the argument, and it should be possible to distinguish between these two cases using appropriate method definitions.
I think there are two options to make the behavior from my first post less surprising.
Option 1
The first option would be to have the first method
f(::Type{F}) where {T, F<:Foo{T}} = 1
be automatically interpreted as
f(::Type{F}) where {T<:Integer, F<:Foo{T}} = 1
i.e., to deduce from the parameter bounds of Foo that T must always be <:Integer for the method to make sense. In general if T is a parameter of more than one type, perhaps this would need to be done using typeintersect.
Option 2
The other way would be to interpret the second signature,
f(::Type{F}) where {F<:Foo} = 2
so that it doesnât have the implicit <:Integer constraint (due to the fact that Foo === Foo{T} where T<:Integer) and acts like
f(::Type{F}) where {F<:Foo{T} where T} = 2
(which is another way to get the behavior I expected).
I think option 1 would be more desirable (because the truth value of Foo === Foo{T} where T<:Integer should probably not be context-dependent). I suspect there may be practical reasons that make option 1 infeasible though, namely that typeintersect(T, S) is not guaranteed to produce the âsmallestâ R such that R<:T and R<:S.
I guess option 3 would be to just acknowledge that this is a bit of a corner case and accept that you need to be more careful in such cases.