[ANN] MultipleInterfaces.jl: Interfaces with multiple inheritance and multiple dispatch

I’m pleased to announce the release of MultipleInterfaces.jl. MultipleInterfaces.jl provides a powerful way to define and work with interfaces in Julia. With MultipleInterfaces.jl you can declare an interface that is defined by a list of required methods, and you can declare which types implement that interface. Interfaces support multiple inheritance, interface intersection, and multiple dispatch. And all with no runtime cost.

You can install MultipleInterfaces.jl from the General Registry:

pkg> add MultipleInterfaces

Here’s a quick example of how to use MultipleInterfaces.jl. For more details, see the README.

using MultipleInterfaces

function a end
function b end
function c end
function d end

@interface A begin
    a
end

@interface B begin
    b
end

@interface C extends A, B begin
    c
end

@interface D begin
    d
end

struct Ant end
struct Bear end
struct Cat end
struct Dog end
struct Mouse end

@type Ant implements A
@type Bear implements B
@type Cat implements C
@type Dog implements D
@type Mouse implements B, D

@idispatch foo(x::Int, y: A, z: D) = 1
@idispatch foo(x::Int, y: B, z: D) = 2
@idispatch foo(x::Int, y: C, z: D) = 3
@idispatch foo(x::Int, y: A, z: B & D) = 4
julia> foo(42, Ant(), Dog())
1

julia> foo(42, Bear(), Dog())
2

julia> foo(42, Cat(), Dog())
3

julia> foo(42, Ant(), Mouse())
4

JuliaCon 2025

Come see my talk about MultipleInterfaces.jl at JuliaCon!

To Do:

  • Add to the documentation and create a Documenter.jl website.
  • Fix some known bugs related to supporting various function definition syntaxes in the @idispatch macro.
50 Likes

Interesting! Could you say a few words how this compares to other similar packages such as GitHub - Seelengrab/RequiredInterfaces.jl: A small package for providing the minimal required method surface of a Julia API or GitHub - mrufsvold/DuckDispatch.jl: If it quacks like a duck... dispatch on it! ?

2 Likes

I’m not an expert on the other packages, but I’ll try to summarize the differences as I see them. First I’ll start with more details about the approach and philosophy of MultipleInterfaces.jl.

MultipleInterfaces.jl

MultipleInterfaces.jl has two main goals:

  • Provide a simple and ergonomic way to define a DAG of related interfaces, via multiple inheritance of interfaces.
  • Allow multiple dispatch on those interfaces.

And a secondary goal:

  • Provide interface intersections that can be dispatched on, like Foo & Bar.

MultipleInterfaces.jl claims to be about interfaces, but in reality it’s an alternative type system with abstract multiple inheritance.

Although MultipleInterfaces.jl provides an alternative type system, the spirit of the type system (“interfaces”) in MultipleInterfaces.jl is very similar to the spirit of the Julia type system:

  • It’s a nominal type system.
  • All interfaces are abstract. Concrete types are final.
  • In analogy to abstract types in Julia, interfaces do not enforce anything about their implementors. They are only used for dispatch.

One of the original inspirations for MultipleInterfaces.jl was the About Interfaces section of @Sukera’s RequiredInterfaces.jl documentation, which, if I recall correctly, discusses multiple inheritance and proposes that interfaces should not contain optional methods. Multiple inheritance provides more flexibility in composing different interfaces, so if we have multiple inheritance we shouldn’t really need “optional” interface methods anymore.

I was also inspired by Haskell type classes, Rust traits, and Java interfaces, all of which support abstract multiple inheritance. I want to be able to define a DAG of interfaces similar to this DAG of type classes in Haskell:

Base-classes

(Image from the Haskell wikipedia page.)

As mentioned, MultipleInterfaces.jl does not enforce any requirements on types that declare that they implement an interface. I recommend that developers who define interfaces also create a unit test suite to test objects that implement that interface, possibly with the help of Supposition.jl.

RequiredInterfaces.jl

RequiredInterfaces.jl seems to be focused mainly on ensuring that an interface implementation has implemented all the methods that are required by the interface. It uses regular abstract types as the flags for an implicit interface, which means that there is no mechanism for abstract multiple inheritance.

Interfaces.jl

Interfaces.jl seems to be focused on testing that an interface has been properly implemented. It allows interfaces to have optional methods. That makes sense in the context of the current Julia ecosystem, but I think optional methods cause a lot of headaches.

Dispatching on interfaces is not really provided by Interfaces.jl. I think Interfaces.jl provides some kind of trait object that can be used in a Holy-trait style, but it’s not well documented. And there is currently no support for multiple inheritance of interfaces. (Actually, I don’t see support for single inheritance of interfaces, either.)

DuckDispatch.jl

DuckDispatch.jl might be the closest to MultipleInterfaces.jl. However, there are some differences:

  • It uses a structural type system (inspired by Go) rather than a nominal type system.
  • Based on the README, it looks like DuckDispatch.jl might impose more requirements on the required methods, whereas MultipleInterfaces.jl takes the traditional Julia approach of relying on docstrings and unit tests to define the expected behavior of the interface.
  • I can’t tell if DuckDispatch.jl supports multiple inheritance of DuckTypes.

Perhaps @mrufsvold can comment more on the details of DuckDispatch.jl and how it differs from MultipleInterfaces.jl.

13 Likes

First of all, congratulations on the release! As the author of RequiredInterfaces.jl, maybe I can also add something :slight_smile:

Conceptually, this seems very close to where I would have wanted to go with RequiredInterfaces.jl, but as you correctly point out, multiple interface inheritance just doesn’t work in the current julia type system if the interface is based on subtyping an abstract type. That’s also the reason it has stagnated a bit. MultipleInterfaces.jl solves this problem by explicitly managing the additional “subtypes” itself, but this of course comes at the cost of having to use @idispatch for defining new methods! If this sort of interface inheritance were supported by the core type system, this would of course not be needed.

One question I do have is how you’re dealing with conflicting interfaces that both require implementing a certain method/signature?

I’m very curious how this package will develop and how the community adopts it :slight_smile:

6 Likes

For the sake of completeness, or to inspire further design decisions, there was a prototype of multiple inheritance in Interfaces.jl: Multiple Inheritance by rafaqz · Pull Request #43 · rafaqz/Interfaces.jl · GitHub

4 Likes

From a technical point of view, there is no issue, since the list of required methods for an interface is not used at all in dispatch. The list of required methods is just there to encourage the idea that an interface is a fixed list of required methods, and to make that list easy to query via required_methods and all_required_methods.

From a conceptual point of view, I hope that commonly used methods can live in small interfaces at the top of the interface hierarchy and be inherited from directly or indirectly by more specific interfaces farther down in the hierarchy. Designing the right hierarchy can definitely be challenging, but that’s the fun part. :slight_smile:

Regarding method signatures, I think as a general design principle, there should be only one docstring per function arity. So, packages that extend that function should only do so by adding specializations that conform to the generic definition.

Not sure if that fully answers your question or not…

2 Likes

Thanks for the cool package! I think that this is a really interesting space in Julia development. I can only speak for myself, but the way I often end up developing the glue code between my package for something (say, some kind of implicit matrix operator thing) and a general-purpose library like Krylov.jl or ArnoldiMethod.jl or whatever is sort of an error loop, where I write all the methods I think my struct needs based on a skim of the docs, hand my object to partialschur or whatever, and then iteratively implement methods I have forgotten until MethodErrors stop coming up. At this point, I think I’m reasonably efficient and even enjoy that loop, but for several people I have introduced to the language who are less familiar with the fundamentals (for whom it may be less obvious that you need to implement Base.eltype(::MyCoolStruct) or whatever, for example) it can be a source of friction.

To be very clear, both of the packages I mention as examples have excellent documentation and tell you everything you need to implement, so this error loop model for development is a personal deficiency and not a critique of Julia or its amazing ecosystem. But I do think it would be cool if over time tools like this got adopted by library-style packages, even just in optional extensions. I bet it would lead to easier onboarding for newer people in particular who are beginning to explore the extreme composability of packages.

6 Likes

I haven’t had a chance to use this package yet, but its functional promises seem too good to be true. It negates the need for Holy traits, and keeps the extensibility of functions AND types as is one of Julia’s strengths.

Does anyone have a number of examples of different data structures and relationships implemented using this package?

If this works, I think it would be worth implementing into Julia base and replacing macros with keywords.

1 Like

I would guess that its drawbacks are exactly as described: @idispatch can more easily run into ambiguities that are harder to fix. I’ll draw your attention to the new SingleArgumentAmbiguityError caused by multiple inheritance (or whatever the more proper term is for interfaces instead of classes), which also adds another layer to the more familiar MultipleArgumentAmbiguityError. To illustrate how this can make things hard, let’s say I’m extending a package’s function for a custom subtype:

import Package: f, B # defines f(::B, ::B)

struct M <: B end
f(::M, ::B) = 1
f(::B, ::M) = -1

The ambiguity of f(::M, ::M) is obvious, but to fully explain it, multiple dispatch searches a mostly linear order of a tuple of types in the method signature. Tuples can have multiple supertypes even if its components can’t, and the resulting branches in method specificity can cause ambiguities if not joined back into the linear order. (Some languages automatically choose which methods in a branch are more specific outside of our control, it’s called linearization.)

  f(::B, ::B)
 /           \
f(::M, ::B)   f(::B, ::M)
 \           /

There are a couple fixes here:

  1. The first documented way is to define a f(::M, ::M) method to cover all the call signatures that can hit this initial method ambiguity. This often makes sense, but not always. After all, if we always need to make this more specific method, then there’s little point in having the less specific and ambiguous ones.
  2. Another way is to simply choose one of the methods to keep. It’s not unusual for some asymmetry to be present in API, and if the 2 ambiguous methods would just do the same thing anyway, it’s not worth having 2 copies.

Now let’s try to apply this to a world with interfaces and multiple inheritance:

import Package: f, B # instead defines @idispatch f(::B)
import Package2: D # also imports f and defines @idispatch f(::D)

struct M end
@type M implements B, D

Now f(::M) runs into the branch:

 /      \
f(::B)   f(::D)
 \      /
  1. I could fix it by implementing f(::M), but it’s even clearer here that I don’t intend to write my own implementation but to reuse more general methods. A more practical example may be that I want MyVector to be both Iterable and Indexable and automatically work for all methods that use those interfaces.
  2. f(::Union{B,D}) doesn’t exist, and even if Package2 did anticipate it, the only way to choose a method is to not use Package2 at all, which shows an extra danger to package composability if the same function takes multiple interfaces as inputs in separate methods.

This is why multiple inheritance is generally discouraged, even in single dispatch. Multiple dispatch introduces ambiguities where single dispatch wouldn’t, but it’s far more natural to split responsibilities and manage. We don’t have static typing or automatic method resolutions to help resolve the ambiguities we already have. Granted, there are places for multiple inheritance, and it can be more easily controlled if we don’t mix multiple methods with multiple interfaces in the same functions. That divergence roughly crops up in other languages too.

5 Likes

That makes a lot of sense, thank you for explaining!
I was wondering what sorts of issues I would face if I started applying this. Thanks for enlightenment.

One thing I’ve been thinking about is that Julia’s type system only allows concrete types to be instantiable, and concrete types cannot have subtypes. A DAG of type hierarchy, if you will, which has been a restriction that has helped avoid and solve lots of people’s problems as per one of many appeals of Julia.

Reading your explanation helps me realise that this package would reinstate such issues present in other languages, and more. So we’d have to look into ways of circumventing or avoiding them.

I know Julia already catches ambiguities, this would need to be checked in this package too. I haven’t looked into whether this package helps the user avoid such issues.

This is amazing! It solves soooo many problems I’ve been encoutering. Not to sell my own proposal, but I’ve developed DelegatorTraits.jl to deal with part of problem: formally dispatch interface methods to a field (like a better formalization of the “inheritance through composition”, or basically an automatic @lazy depending on the interface that the function belongs to).

I would like to integrate it with MultipleInterfaces.jl but I have a couple of questions:

  1. Are @interface objects singleton types? Like can I instantiate A() from your example?
  2. Is there a programmatic way to tell MultipleInterfaces.jl that a type implements an interface?
2 Likes

Presumably this is a case of two non-orthogonal traits that both warrant certain specializations of f. Wouldn‘t there need to be a way to express „M implements B regarding f in addition?

Multiple inheritance is often discouraged in languages with concrete inheritance, like Python and C++. The situation in Julia is a little different.

Ambiguities certainly can be a problem, but I think they won’t be as much of a problem as one might think, which I will elaborate on below. First let me go over the examples again.

Benny’s example can be easily fixed. Since Package2 knows about interface B, it can define an @idispatch on B & D, like this:

@idispatch f(x: B & D) = # ...

But we can come up with a more difficult situation, which is probably what Benny intended. I’ll describe that situation below, but first an aside.

This is not strictly needed for the discussion of ambiguities, but for the sake of completeness, I’ll mention that when designing a generic function f, it should have a single, generic docstring. Further specializations of f should not add new docstrings. A consequence of this is that the “hierarchy” of dispatches on f (for a single argument) should have a single interface at the top, which is the interface mentioned in the docstring. Specializations of f should dispatch on subinterfaces of that “top” interface.

So, here’s an example with a dispatch ambiguity that is challenging to resolve:

Single argument dispatch ambiguity

PackageA

function a end

@interface A begin
    a
end

"""
    foo(x: A)

Here is the generic definition of `foo`, which expects it's 
argument `x` to implement the interface `A`.
"""
@idispatch foo(x: A) = 1

PackageB

using PackageA: foo, A

function b end

@interface B extends A begin
    b
end

@idispatch foo(x: B) = 2

PackageC

using PackageA: foo, A

function c end

@interface C extends A begin
    c
end

@idispatch foo(x: C) = 3

User code (or a fourth package)

using PackageA: foo
using PackageB: B
using PackageC: C

struct Mouse end

@type Mouse implements B, C

foo(Mouse())

The foo(Mouse()) call above throws an ambiguity error. The ambiguity error could be fixed by either PackageB or PackageC adding a package extension that defines a foo(x: B & C) dispatch. That’s not an ideal solution, but it is tenable. However, I’m also going to argue in the next section that such ambiguities should be relatively uncommon. So, in the uncommon cases when an ambiguity does arise, it can be fixed with a new @idispatch method in a package extension.

Ambiguities should be uncommon

There are two main categories of methods, and it’s useful to distinguish between them:

  • Required methods:
    • methods that must be implemented for an interface
    • might have dozens of implementations
    • usually dispatch on concrete types
      • thus, they don’t have ambiguities
  • Provided methods:
    • generic functions defined in terms of required methods
    • usually dispatch on abstract types (or interfaces)
    • usually only a handful of specializations
      • often, all implementations are defined by the function owner
      • thus, ambiguities should be uncommon

One of the main reasons to implement an interface is to get all the provided methods for free. So if you create a custom type and you implement an interface for that type, you probably won’t add any specializations to the provided methods.

That’s not to say it won’t ever happen. If a library author creates an interface that extends an interface from another package, then they might also create additional specializations on their new interface for some provided methods.

So, to summarize, required methods shouldn’t have ambiguities, since they dispatch on concrete types. And ambiguities in provided methods will hopefully be uncommon, since there are typically only a small number of specializations, and those are often implemented by the function owner. And if absolutely necessary, ambiguities can be resolved in a package extension.

Here’s a concrete example that is inspired by Base Julia code, but is not actually Base code:

function iterate end
function length end

@interface Iterator begin
    iterate
end

@interface SizedIterator extends Iterator begin
    length
end

@idispatch mycollect(x: Iterator) = # ...
@idispatch mycollect(x: SizedIterator) = # ...

In this example:

  • iterate and length are required methods
    • They will likely have hundreds of implementations for concrete types across the ecosystem.
  • mycollect is a provided method
    • The owner of the above code owns both implementations of mycollect.
    • It’s likely that no other packages will define additional implementations of mycollect, in which case there will never be any ambiguities.
2 Likes
  1. Interface objects are singleton types, but that’s an implementation detail, so I wouldn’t recommend relying on it. Also, interface intersections are not singleton types.
  2. No, right now the only way to declare an implementation is with the top-level @type macro, like @type Foo implements Bar.
    • This doesn’t seem likely to change, but if you have a convincing use case I might contemplate it.

MultipleInterfaces.jl operates very similarly to the type system in Julia. The semantics are nearly the same, except that interfaces support abstract multiple inheritance. And, just like types, interfaces (and implementations) can only be declared at the top level.

By the way, MultipleInterfaces.jl was added to the General Registry some time ago, so you can install it like so:

pkg> add MultipleInterfaces
2 Likes

I’m curious how one might extend this to cases where a required method is shared between multiple interfaces. After all, a core tenet of Julia’s multiple dispatch is that the first argument isn’t necessarily special and doesn’t own the method, so it wouldn’t always make sense for an interface A alone to require a method of f. Sometimes, interfaces A and B are a package deal—an implementation of one is only useful along with an implementation of the other—and together, they require a method @idispatch f(x: A, y: B).[1]


  1. I was typing out a worked example with VectorSpace and ScalarField interfaces and scalar multiplication as the shared required method, but then I realized that this is a one-way dependency—a vector space needs a field, but not the other way around—so scalar multiplication unambiguously belongs to VectorSpace. Coming up with a better example is left as an exercise to the reader. ↩︎

I think it’s best to avoid that if possible. With multiple inheritance and interface intersections, we have enough flexibility to express various combinations of methods. Here’s a very hypothetical sketch of a hierarchy of collection interfaces. (It’s definitely not consistent with Base Julia 1.x.)

In addition to the above hierarchy, we can also define a useful alias for an interface intersection, like this:

const SizedIterator = Iterator & HasLength

So, we see that length and iterate get reused in various interface extensions and interface intersections without having to duplicate those functions across independent interfaces.

However, you raise some interesting questions about required methods with multiple arguments. Let’s consider a paired down example of a graph interface:

""""
    source(e: DirectedEdge)

Get the source vertex for directed edge `e`.
"""
function source end

"""
    destination(e: DirectedEdge)

Get the destination vertex for directed edge `e`.
"""
function target end

@interface DirectedEdge begin
    source
    target
end

"""
    add_edge!(g: Graph, e: DirectedEdge)

Add the directed edge `e` to the graph `g`.
"""
function add_edge! end

@interface Graph begin
    add_edge!
end

In this case, the Graph interface and the DirectedEdge interface are closely related to each other, but there is no inheritance relationship between them. However, at least in this case, I think it’s still natural for the add_edge! function to be a required method of only the Graph interface and not the DirectedEdge interface.

…The argument annotations in the docstrings are potentially confusing. They’re not meant to indicate that those required methods are defined with @idispatch. Rather, they indicate the interfaces that are expected to be implemented by the objects that are passed to each argument of the required method. Although, for required methods with multiple arguments, we might actually want to use @idispatch like below, where we dispatch on a concrete type in the first argument and the DirectedEdge interface in the second argument:

@idispatch add_edge!(g::MyConcreteGraph, e: DirectedEdge) = # ...
2 Likes

Thinking more about this, it seems likely that any method that belongs to more than one interface, say, something like solve(problem, algorithm, options), would be a provided method, not a required method. The fact that required methods can only belong to a single abstract interface may actually be a helpful restriction, providing implicit guidance on what kinds of methods to require.

2 Likes

Good point in bringing up interface intersections as a more general alternative to annotating the concrete type for disambiguating a call, but that still doesn’t seem tenable.

  1. PackageB and PackageC share a dependency PackageA (for the function foo and interface A), but they are independent packages. We expect independent packages to work together even without an extension (the developers do not even need to know about each other), so an extension being necessary to fix foo’s dispatch for mundane usage of Mouse implements B, C is a new hurdle to composability.
  2. Say there’s a PackageD that mirrors PackageB and PackageC. Like the 1st user, a 2nd user makes @type Vole implements C, D, and a 3rd user makes @type Rat implements B, D. To fix their respective dispatch issues, we can make 2 more extensions that define foo(x: C & D) and foo(x: B & D). That’s an extension for every possible unordered pair of interfaces, in other words given n interfaces, we need C(n, 2) = n!/(2!(n-2)!) extensions, and that’s if we assume everyone only ever needs to implement 2 interfaces for a type.
  3. Now comes a last user who decides to make the triple threat: @type Jerboa implements B, C, D. Importing all 3 packages triggered all aforementioned extensions. Unfortunately, foo(Jerboa()) runs into the ambiguity foo(x: B & C) vs foo(x: C & D) vs foo(x: B & D). We needed yet another extension to define foo(x: B & C & D). So, the assumption in (2) wasn’t good, we actually needed \Sigma_{k=2}^{n}C(n, k) = 2^n-1-n extensions (PackageA’s method would be the 1, the n would be the methods in PackageB, PackageC, and PackageD). This kind of method explosion is exactly why Julia opted not to allow multiple methods with respect to keyword arguments, only instead of automatic code bloat with default values, the developer community would be inundated with ad hoc “Please implement multiple interface inheritance for this package combination” issues.

Which brings me to your distinction between (1) the required methods implemented for interfaces and (2) the provided methods that dispatch on the interfaces and call the required methods. In languages with interfaces (or something like it by another name) but no automatic resolution, you might implement a method for multiple interfaces’ shared name and compatible method signature (this seems more like the case for Julia’s module-encapsulated multimethods and MultipleInterfaces.jl, and if not compatible, you might be out of luck before you can even attempt a dispatch ambiguity), or you might implement it for each interface separately and disambiguate calls via static typing in provided methods (e.g. foo(x: A) will treat Jerboa() as A, not B or C) or manual specification in other calls (if you neglect to do this, you will run into a dispatch ambiguity). On the other hand, multiple methods for a provided method name is strongly discouraged or disallowed (e.g. Rust doesn’t do function overloading). The annotated interfaces (or combinations thereof) specify necessary characteristics for the inputs of the provided method, and the polymorphism is the variety of types that implement those interfaces; another provided method that works on different annotated interfaces wouldn’t be considered or allowed to have the same name. The kind of polymorphism with multiple inheritance really doesn’t mix well with that of multiple dispatch (well, this is actually rare, so I mostly see this represented by single-dispatch duck typing).

A question that was asked in your talk is, how can an @idispatch method be defined for an interface intersection when Julia doesn’t have concrete type inheritance?

From what I understand, the way it would do that is not with field-accessor syntax, but with functions that are defined for both types. Which is a design many packages choose, especially with Holy traits. In the example on slide 21, vertices is required for both UndirectedGraph and DirectedGraph so the method foo would call vertices in its body.

It makes sense to me that you wouldn’t define a method for an interface/object that its instances wouldn’t have properties for.

In my opinion, the examples throughout the slides aren’t the best in demonstrating some excellent multiple inheritance examples alongside @idispatch methods, which was what I was excited for and expecting in the presentation, e.g. the slide with animals like Mouse could be improved by inheriting from Mammal or such, instead of letters like A. And Mammal requires birth… a Platypus can maybe inherit from Mammal and EggLayer (but that’s a whole other can of worms - implement birth with error-throwing haha). And then define some @idispatch methods on the traits and trait intersections.

By the same token, @CameronBieganek does note that we need more real world examples, and I’ve had this in the back of my head for a while, I’ve also been thinking about how this functionality could benefit my packages, but I’d want to do it in a separate package.

I think a challenge this package will face in adoption is, people might not want to mess up their current implementation for something they’re not fully sure will work, or might make things worse.

And that’s on top of the ambiguities we already know could/might be a problem.

Regardless, I’m excited to see where this leads to. Keep up the great work @CameronBieganek!