Passing through `==` for subtypes of a parametric abstract type with differing parameters

Say I define AbstractFoo{T}, which has no supertype besides Any, but has subtypes Bar{T} and Baz{T}:

julia> abstract type AbstractFoo{T}
       end

julia> struct Bar{T} <: AbstractFoo{T}
           value::T
       end

julia> struct Baz{T} <: AbstractFoo{T}
           value::T
       end

Since others might want to add subtypes of AbstractFoo, we have a getter method for its value parameter as part of the interface, so even if a subtype doesn’t define a field named value, you define qux for it.

julia> qux(x::AbstractFoo) = x.value

By default, == falls back to ===, so Bar{S}(value) will not equal Bar{T}(value) in general:

julia> Bar{Float32}(1) == Bar{Int}(1)
false

julia> Baz{BigInt}(1) == Baz{ComplexF64}(1)
false

To get around this, you could just define equality methods for Bar and Baz instances:

julia> Base.:(==)(x::Bar, y::Bar) = qux(x) == qux(y)

julia> Bar{Float32}(1) == Bar{Int}(1)
true

julia> Base.:(==)(x::Baz, y::Baz) = qux(x) == qux(y)

julia> Baz{BigInt}(1) == Baz{ComplexF64}(1)
true

But this doesn’t solve the problem for anyone who derives a subtype of AbstractFoo. So a more general method is needed. You can’t define

Base.:(==)(x::AbstractFoo, y::AbstractFoo) = qux(x) == qux(y)

if you don’t want all AbstractFoo subtypes to be considered == equal, and there’s no point in defining

Base.:(==)(x::T, y::T) where {T<:AbstractFoo} = qux(x) == qux(y)

because that will only dispatch if both x and y are exactly the same type (which == already handles).

What is the correct way to write an equality method for == in this case, where I want to dispatch on types sharing a struct definition but differing in parameter?

This could work.

Base.:(==)(x::AbstractFoo, y::AbstractFoo) = typejoin(typeof(x), typeof(y)) |> isstructtype && qux(x) == qux(y)

Since struct could only subtype an abstract type, this checks if the closest common ancestor type of x and y is an abstract type. If it is an abstract type, they do not subtype a common struct, and thus it must be false; otherwise, test the two with qux.

1 Like

Am I misreading this? It sounds like you’re asking for a general AbstractFoo equality method at first, but you don’t want this exact method because there should be exceptions, that is some pairs of subtypes can’t be equal. It just seems to be a really unusual characteristic, and the exceptions are too vague to suggest anything specific.

In my understanding, as in my first reply, I think they meant the comparison should only work for the same subtype of AbstractFoo, thus Bar and Bar but not Bar and Baz, also not for arbitrary AbstractFoo subtypes. It’s a weird thing to consider but I guess it might be needed somehow.

Perhaps it would be better to use isstructtype rather than !isabstracttype, but the distinction may not matter for this particular case?

Basically, if qux(x) == qux(y), then x == y for all structs descending from AbstractFoo, if they share the same struct type.

Maybe to make it more concrete, perhaps you have a supertype AbstractScaledShape{T}, derived types RightTriangle{T}, Square{T}, etc, and getter scale_factor(::AbstractScaledShape). The shapes will be different and it wouldn’t make sense for different shapes to be equal in any way, but Square{S}(x) == Square{T}(x) and RightTriangle{S}(x) == RightTriangle{T}(x) for any x convertible to S and T would make sense.

I guess this may be a problem where dispatch tools aren’t actually the right approach.

That’s a really solid example. I modified my reply to use isstructtype, it should work for this scenario? Could you elaborate on why you’d think dispatching is not the ‘right’ approach?

Just in the sense that you can’t accomplish the desired type selection with Julia’s tools for method dispatch; as in your solution the tests have to be done in the function body.

Perhaps there is a way, but I can’t conceptualize how the method signature would look.

Indeed, that method is definitely not using argument type dispatch per se, which is unfortunate. UnionAll could only narrow down to concrete types, I’m not optimistic enough to think it could be changed, since there might be scenarios stopping this from happening.

Weren’t you close with this?

Base.:(==)(x::AbstractFoo, y::AbstractFoo) = false
Base.:(==)(x::T, y::T) where {T<:AbstractFoo} = qux(x) == qux(y)

This iterated dispatch would make the comparison of Bar(0) and Bar(0.0) to return false, for instance, which is not the desired outcome.

No, because the second method would only dispatch if x and y are exactly the same type. If typeof(x) === Bar{Int16} and typeof(y) === Bar{BigFloat}, it would dispatch to the first method and always return false.

Ah I get it now, I was stuck thinking of concrete types, missing the abstract parametric struct types. I think you’re right that method signatures don’t support this; method parameters only stand in for full types ::T or type parameters ::Vector{T}, not parametric types ::T{Int}; I don’t know type theory but that’s probably for reasonable attempts to sort sets of methods by specificity.

You could technically split an algorithm across a helper multimethod dispatched over Holy traits, singletons mapped to ideally statically computed conditions, to get around limitations of method dispatch. But a Samestructtype() trait method is not really warranted here because lilachint got it done in one method, even one line.