Method specificity with parametric type constructors

I’ve encountered what appears to be an inconsistency with method specificity. However, since it’s considerably more likely that I’m just confused, I thought I’d ask here before creating an issue.

My basic expectation is that in the following simple example the method that uses a type parameter constrains the two arguments to be of the same type, and so it is more specific:

julia> foo(x::Integer, y::Integer) = "generic"
julia> foo(x::T, y::T) where {T <: Integer} = "specific"

julia> foo(1, 2)
"specific"

julia> foo(Int8(1), 2)
"generic"

That indeed works as expected. If I introduce a parametric type:

julia> struct Foo{T <: Integer}
    x::T
    y::T
end

julia> methods(Foo)
# 1 method for type constructor:
[1] Foo(x::T, y::T) where T<:Integer in Main

julia> Foo(x::Integer, y::Integer) = "generic"

julia> Foo(1, 2)
Foo{Int64}(1, 2)

julia> Foo(Int8(1), 2)
"generic"

That also follows my expectations. If I introduce one more parametric type:

julia> struct Bar{T}
    x::Foo{T}
    y::Foo{T}
end

julia> methods(Bar)
# 1 method for type constructor:
[1] Bar(x::Foo{T}, y::Foo{T}) where T in Main

julia> Bar(x::Foo, y::Foo) = "generic"

julia> Bar(Foo(1, 2), Foo(1, 2))
"generic"

julia> Bar(Foo(Int8(1), Int8(2)), Foo(1, 2))
"generic"

Why didn’t the first call to Bar dispatch to the constructor provided by Julia?

Good question. I believe there is some nuance (maybe even a bug) when dealing with parametric type constructors, because I have already seen confusing problems related to them popping up in the Discourse before.

My takeaway from these previous problems is that parameterized struct constructors may behave kinda weirdly if you do not parameterize the call with the desired type. So, in your case:

julia> Bar{Int}(Foo(1, 2), Foo(1, 2))
Bar{Int64}(Foo{Int64}(1, 2), Foo{Int64}(1, 2))

Gives the expected result, however, you cannot parameterize your outer constructors so:

julia> Bar{Int}(Foo(Int8(1), Int8(2)), Foo(1, 2))
ERROR: MethodError: Cannot `convert` an object of type 
  Foo{Int8} to an object of type 
  Foo{Int64}
Closest candidates are:
  convert(::Type{T}, ::T) where T at essentials.jl:171
  Foo{Int64}(::Any, ::Any) where T<:Integer at REPL[5]:2
Stacktrace:
 [1] Bar{Int64}(::Foo{Int8}, ::Foo{Int64}) at ./REPL[10]:2
 [2] top-level scope at REPL[17]:1

I believe that maybe methods is lying to us, and there is in fact a Bar(x::Any, y::Any) which calls the adequate parameterized version.

I’m not sure why you think methods is lying. I think you are just seeing the distinction between methods(Bar) and methods(Bar{Int}):

julia> methods(Bar)
# 1 method for type constructor:
[1] Bar(x::Foo{T}, y::Foo{T}) where T in Main

julia> methods(Bar{Int})
# 1 method for type constructor:
[1] Bar{T}(x, y) where T in Main

In my case, I am trying to write this generically, so I don’t know the parametric type in order to call Bar{T}(x, y) directly. My first instinct is to write a method to grab the right parametric type. Something like:

Bar(x::Foo{T}, y::Foo{T}) where {T} = Bar{T}(x, y)

But, of course, that’s the method that Julia already supplied that is causing problems.

I was able to work around this issue by being careful to not define the Bar(::Foo, ::Foo) method, so that the Julia-provided constructor is still accessible. Something like:

# Don't define this method from above
# Bar(x::Foo, y::Foo) = "generic"

Foo(x::Integer) = Foo(x, x)
Foo(x::Foo) = x
Base.convert(::Type{Foo{T}}, x::Foo) where {T} = Foo{T}(x.x, x.y)
Base.promote_rule(::Type{Foo{S}}, ::Type{Foo{T}}) where {S, T} = Foo{promote_type(S, T)}

Bar(x, y) = Bar(promote(Foo(x), Foo(y))...)

That does seem like a bug doesn’t it.

What happens with this instead?

Bar(x::Foo{S}, y::Foo{T}) where {S,T} = "generic"

Oh, very clever! That does work!

At least something works! But I would have thought those two versions would be equivalent dispatch-wise (there are no cases where one matches and the other doesn’t) so feels like a bug to me.

I’ll make an issue.

1 Like
1 Like