I’m currently working on a package called ExtendableInterfaces.jl
that provides multiple inheritance among interfaces, and multiple dispatch on interfaces. (Hopefully I can find some time during the holidays to get it over the finish line. Though I also have to write the documentation… ) My hope is that if users develop interfaces and generic functions with the right design principles, then method ambiguities will be manageable. However, we won’t really know if multiple inheritance is feasible until a decent subset of the ecosystem tries it out in practice.
Let’s consider an example similar to the one provided by @Mason. We only need single-argument functions to demonstrate the ambiguities that can arise with multiple-inheritance, so I will stick with single-argument functions for this example. The syntax used is that of ExtendableInterfaces.jl
, but I think it should be fairly intuitive to read.
There are four packages in this example:
Pkg1
function a end
@interface A begin
a
end
"""
foo(x: A)
Foo-inate an `A`.
"""
@idispatch foo(x: A) = (a(x), 42)
Pkg2
using Pkg1: Pkg1, A, a, foo
function b end
@interface B extends A begin
b
end
@idispatch Pkg1.foo(x: B) = (b(x) * a(x), 42)
Pkg3
using Pkg1: Pkg1, A, a, foo
function c end
@interface C extends A begin
c
end
@idispatch Pkg1.foo(x: C) = (c(x) * a(x), 42)
Pkg4
using Pkg1: foo
using Pkg2: B
using Pkg3: C
struct Cat end
@type Cat implements B, C
foo(Cat()) # ambiguity error
In this case, foo(Cat())
results in an ambiguity error. There’s nothing that Pkg4 can do about it (or nothing that they should do about it), because Pkg4 owns neither foo
nor A
, B
, or C
, so adding another @idispatch
for foo
would be interface piracy.
However, there is a rule of thumb for concrete types that I would propose:
- Avoid implementing two or more interfaces that have a common superinterface.
- If you own at least one of the interfaces that you are implementing, then it’s probably not an issue, since you can define dispatches on interface intersections (denoted
B & C
in ExtendableInterfaces.jl
).
That might sound restrictive, but I think in practice it’s often pretty natural. For example, it would be reasonable for a type to implement two orthogonal interfaces, Iterable
and PushAndPoppable
, like this:
struct Dog end
@type Dog implements Iterable, PushAndPoppable
There are some other interface and generic function design principles that collectively might help to reduce ambiguities. First, a few definitions:
- A required method is a method that must be implemented by a type as part of the requirements for an interface.
- A derived method is a method that is defined using the required methods for various interfaces (and possibly other derived methods).
- All functions in Julia are generic functions. (A single function can have multiple implementations.)
So, in the example above, the functions a
, b
, and c
are required methods, and foo
is a derived method.
Design principles:
- Required methods are normally only implemented for concrete types.
- Derived methods are implemented for abstract types (and for “interfaces” in
ExtendableInterfaces.jl
).
- Generic functions should have only one docstring per function arity. In other words, there should be exactly one defined behavior for each function arity.
- There are some exceptions to this rule, like constructors and
convert(T, x)
, where the exact behavior depends on the input types.
Note in the example above, Pkg1 provides a single, generic docstring for foo
, and Pkg2 and Pkg3 do not add to the docstring, since they should not change the behavior of foo
when they provide specializations for the subinterfaces B
and C
.
Finally, let’s consider a modified version of the above example where interfaces B
and C
are provided by the same package, Pkg2b. Then Pkg2b can use an interface intersection (denoted B & C
) to disambiguate the dispatch for types that implement both B
and C
, like this:
Pkg2b
using Pkg1: Pkg1, A, a, foo
function b end
function c end
@interface B extends A begin
b
end
@interface C extends A begin
c
end
@idispatch Pkg1.foo(x: B) = (b(x) * a(x), 42)
@idispatch Pkg1.foo(x: C) = (c(x) * a(x), 42)
@idispatch Pkg1.foo(x: B & C) = (b(x) * c(x) * a(x), 42)
struct Chameleon end
@type Chameleon implements B, C