Just to preface, this could warrant splitting the topic because it’s getting farther from the original topic of not intuitively knowing what method a call dispatches to. Or it may not because this is precisely about failing to realize the ambiguous methods a possible call will fail to dispatch to.
That PR doesn’t actually capture the cause of the ambiguity. It strictly blames uneven argument type annotations, that is foo(::A, ::B) where (A <: B) && !(B <: A), pointing to real code in MultivariatePolynomials.jl:
Base.:(==)(p::RationalPoly, q::RationalPoly) = p.num * q.den == q.num * p.den
...
Base.:(==)(α, q::RationalPoly) = α * q.den == q.num
Base.:(==)(q::RationalPoly, α) = α == q
The consequence is that while MultivariatePolynomials.jl itself would dispatch fine, another package that tries to extend == in the same way will create sources of ambiguity for code that mixes the packages, very possibly including itself. To adapt the general example to this case:
module MyModule
struct MyType end
Base.:(==)(x::MyType, y::MyType) = ...
Base.:(==)(x, y::MyType) = ...
Base.:(==)(x::MyType, y) = ...
A user tries:
using MultivariatePolynomials, MyModule
MyType(...) == RationalPoly(...) # MethodError!
#=
Candidates:
Base.:(==)(x::MyType, y)
Base.:(==)(α, q::RationalPoly)
=#
The thing is, the same consequence can happen with even argument type annotations. Let the first package have:
module A
foo(x::Real, y::Real) = ...
And another package does:
module B
using A: foo
struct MyNum <: Real end
A.foo(a::Number, b::MyNum) = ...
A.foo(a::MyNum, b::Number) = ...
A.foo(a::MyNum, b::MyNum) = ...
so a user tries:
foo(3.14, MyNum()) # MethodError!
#=
Candidates:
foo(x::Real, y::Real)
foo(a::Number, b::MyNum)
=#
To visualize it, drawing a line through columns of parts of the type hierarchy for each ambiguous method signature will show intersections, whether it’s within the same branch (MyNum <: Real <: Number) or across different type branches (RationalPoly vs MyType) with a shared parent node (Any). Swapping uneven argument type annotations would produce more of an X, but the intersection can also have a fully horizontal line. The only method signatures whose lines CANNOT intersect any other’s are:
- all leaf type annotations: this includes
::Type{T} for type input T and concrete types for everything else
- all
::Any annotations for arguments, and the callable type annotation that only strictly subtypes Function or Any. That’s because Function, Any, and direct type parameters are disallowed in the callable’s type annotation.
While it does help to discourage shared supertypes (
Any is the easy one) in order to separate type hierarchies per position, it’s important for functionality sometimes. The annotations pattern in MultivariatePolynomials.jl or module
B are also important for functionality and widely used (and
documented) to resolve method ambiguities. Currently, I think there are two takeaways:
-
An acknowledgement that extended functions e.g. Base.== do not need to support arguments mixing types among unrelated dependents e.g. MultivariatePolynomials vs MyModule. Putting aside the lack of need and likely impossibility, one of the packages had to be aware of the other to implement ==, and it’s not reasonable to expect everyone to know what everyone else is doing with ==. A dependency implementing its interfaces well enough to work well in a dependent’s new contexts is already difficult enough, we shouldn’t expect the infeasible from composability.
-
If one package is in fact aware of the other (B is clearly aware of A through the extended function foo), there is a responsibility to make the types work together without ambiguity in the extended method table or resort to a new function with a fresh method table. If you can get away with it, only use type annotations in your own type hierarchies (iffy on exactly what is necessary, I think only one position is needed, a stronger version of what’s suggested to prevent type piracy from breaking preexisting code). In short, know the method table or leave it alone!