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!