Interfaces/traits in julia 2.0 and multiple inheritance

I will take an ignorant stab at this, but I believe what we really need is more powerful unions that can be put into function signatures and extended afterwards. For instance, if we were able to do a simple thing like:

const FieldTrait = Union{<:Integer} # Which could be defined elsewhere

f(x::FieldTrait) = 2*x

struct MyField
    x
end
const FieldTrait = Union{MyField, FieldTrait}

where the function f would reference the new union there would be no need for a trait system in Julia. The trait constraint then becomes intersection of unions which is easally to grasp and does not require any new additions to dispatch logic and reasoning how it works out.

For me the key deterent for trying to incorporate any of the trait libraries is that they often require the use of macros for the function definition which obscures debugging. Even more so when having had experiences of debugging dispatch ambiguities and trying to figure out how to resolve them constructively.

This is already possible in julia with UnionAll type

function f(x::T) where T <: Integer
    2*x
end

struct MyField <: Integer
    x
end

Base.:*(x::MyField, y::Integer) = MyField(x.x * y)
Base.:*(x::Integer, y::MyField) = MyField(x * y.x)

f(MyField(2))

T where T <: Integer is essentially the same as Union{<:Integer}

julia> (T where T <: Integer) === (Union{<:Integer})
true

const FieldTrait = Union{MyField, FieldTrait} semantically is the same as struct MyField <: Integer

1 Like

I love these discussions, even though I believe I can feel the mods and people writing PRs to base rolling their eyes: “Not this again” :rofl:

All I know is the problems I encounter and what it feels like I am missing, with little understanding of how added features will wreak havoc. I have not tried to write a sparse library.

From this vantage, it seems like multiple inheritance/traits are not a big improvement for me, but I would like concrete subtyping. The stupid way I see this working is simply make T <: Abstract{T} so that there is just this automatic extra node in the type graph. The one tiny change is that all function signatures x::T get lowered to x<:Abstract{T}

Then if you want to subtype S <: :Abstract{T} you have to make sure that you have overloaded getfield(x::S,:fieldnameofT). Bam. Concrete subtyping. Problem solved? Am I a moron or a genius?

And yeah, just like public, we just need a base-approved way to name interfaces, kind of an orthogonal issue though but the most obvious interface is this kind of “shadow” implementation of another concrete subtype.

3 Likes

I am just illustrating how to not commit fully on multiple inheritance while enabling expermentation on different trait system designs. What I would find more realistic is that one would create a function for appending a type to the union which runs various checks on implemented interface. Probably it would converge to something but at the moment it is hard to tell.

Keno has a document on a possible interfaces design: A roadmap for interfaces - HackMD

11 Likes

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… :grimacing:) 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
12 Likes

Let me expand a little on how the design principles reduce ambiguities.

If we follow the principle that a generic function should have only one docstring per arity (i.e. one behavior per arity), then the following ambiguity would be avoided:

function b end
function c end

@interface B begin
    b
end

@interface C begin
    c
end

@idispatch foo(x: B) = 1
@idispatch foo(x: C) = 2

struct Cat end

@type Cat implements B, C

foo(Cat())  # ambiguity error

Of course, the same kind of ambiguity can still occur when we do follow that design principle, as illustrated in the first example in my previous post (where the hierarchy is B ← A → C and the foo docstring is defined in terms of the interface A). However, if we also follow the rule of thumb that concrete types should avoid implementing two or more interfaces that have a common superinterface, then we also avoid the ambiguity in the B ← A → C case.

The rule of thumb might be closer to a requirement than a guideline, since users might be forced to adhere to it in order to avoid ambiguities…

Finally, there shouldn’t be ambiguities with interface required methods, since the design principle for required methods is that they should normally only dispatch on concrete types (or parametric types or unions that you own, etc). Granted, the large majority of functions used in the wild can be considered derived methods rather than required methods, but at least that’s one category of functions where we don’t need to worry about ambiguities.

All that being said, single inheritance combined with duck typing is already pretty powerful, as @jakobnissen mentioned, so it remains to be seen whether the benefits of multiple inheritance outweigh the costs.

4 Likes

I think a really good practical case study for Interfaces/Traits and ambiguity is unit systems, particularly DynamicQuantities.jl. The problem here is that in theory, you could attach unit information to anything

Quantity{T, U::AbstractUnits}

but in order to use it, functions need to be generic enough to support Quantity. There are a lot of functions that support the Real abstract type, and Quantity{T<:Real, U::AbstractUnits} should technically be able to work in those cases. So you could subtype

struct NumberQuantity{T<:Number, U::AbstractUnits} <: Number
...
end

struct RealQuantity{T<:Real, U::AbstractUnits} <: Real
...
end

struct ArrayQuantity{T<:AbstractArray, U::AbstractUnits} <: AbstractArray
...
end

If we want to overload all the core mathematical methods of the base type and slot in the methods for Quantities it would just work most of the time. However, trying to do this by dispatching on a TypeUnion opens up a ton of ambiguities so a lot of things end up being huge meta-programming loops. If we could define a Quantity trait, we could define all of the relevant mathematical operators once and dispatch on that first before trying anything else, that would solve a lot of my problems.

1 Like

The key point here is not the trait, but the dispatch order. Unless there’s a way to specify priorities, what gets dispatched in what order, potential traits won’t help dealing with ambiguities. And the other way around, if there was a way to define the dispatch order, ambiguities could be resolved even with regular types and not traits.

With units specifically, note that there’s generally no need for ArrayQuantity as a separate type – just use Array{Quantity}, it should be just as performant and more composable.
I think there’s also no need for NumberQuantity if RealQuantity existed.

Still, no doubt there are similar usecases where multiple related types are needed in different places of the types hierarchy. Think about values-with-uncertainties for example: unlike units, “complex number with uncertainty” or “xy coords with uncertainty” generally cannot be decomposed into individual components + univariate uncertainties.

3 Likes

For example, Rust can implement multiple traits for a type, but one must manually disambiguate methods shared by traits; trait overlap is warned against, not an encouraged feature. It’s a deliberate choice because the automatic alternatives are based on typically unimportant and unstable factors like definition order. We can attach multiple Holy traits to a type or sequence of types because the context selects the one-to-one trait-sets for dispatch, which is much more limited.

Multiple dispatch already contends with an ambiguity issue because a tuple of single-supertyping types (of multiple arguments of a call) branches to multiple tuple supertypes, a dispatch ambiguity resolved by a more specific method (kinda defeats the point of polymorphism) or picking the best branch for methods; doing that to multiple inheritance/supertyping/traits would only result in single inheritance/etc.

2 Likes

Another concrete example where multiple inheritance or first-class traits would dramatically simplify things is BorrowChecker.jl.

Currently, to use borrowed wrapper types (like BorrowedMut{T}) with existing functions, downstream users must manually rewrite all signatures in their code:

- function my_mutating_function!(x::AbstractDict)
+ function my_mutating_function!(x::@&(:mut, AbstractDict))

which expands to Union{AbstractDict,BorrowedMut{<:AbstractDict}}.

If Julia allowed multiple inheritance or first-class traits, we could instead define something like:

struct BorrowedMut{T} <: Concat{AbstractBorrowed{T}, T}
    #= ... =#
end

This way, BorrowedMut would always inherit methods from AbstractBorrowed if they are defined, and only fallback to T otherwise. It’s like you are concatenating type hierarchies, so the dispatch priority becomes:

BorrowedMutAbstractBorrowedTsupertype(T) ≻ … ≻ Any

This would automatically ensure compatibility without requiring invasive rewrites. Alternatively, if Base itself used traits, external trait packages like SimpleTraits.jl or WhereTraits.jl could offer an elegant solution – but these packages currently can’t apply traits directly to Base methods!

As mentioned by @Deduction42 earlier in this thread, another workaround (used by DynamicQuantities.jl to have a RealQuantity <: Real alongside a Quantity <: Number) is to generate wrapper-specific abstract types such as:

abstract type BorrowedMutAbstractDict{K,V} <: AbstractDict{K,V} end
abstract type BorrowedMutAbstractArray{T,N} <: AbstractArray{T,N} end

However, this quickly becomes unmanageable, even for a small subset of Julia’s abstract types. It requires you to overload the method on Base, independently, for every single abstract type. This leads to disambiguation hell.


P.S., does anybody know of a language that has both multiple dispatch and multiple inheritance? I’m curious what constraints it imposes.

1 Like

Sorry if I wasn’t clear, but this is exactly what I’m talking about. DynamicQuantities.jl uses TypeUnions to approximate multiple inheritance. However, without a way to specify that you need to first apply f(x::Any,y::AbstractQuantity{T}) you will get a lot of ambiguities. In a similar manner, traits will result in a lot of ambiguities without a way of treating AbstractQuantity{T} as a bottom-level type.

Ideally, for my own package, I would only need units to apply to Real and Any which is what I attempted to do. This still resulted in a lot of ambiguities, but I resolved them. However, any attempt to extend this functionality to arbitrary objects that defined {+,-,/,*} with <:Real like TimeRecords.jl results in more ambiguities. At this rate, I might have to bite the bullet and support only Quantity{T, AbstractUnits} where T <: Any and forego numeric subtyping altogether. This is annoying because users would have to patch every function not general enough to support Quantity{T} but at least it can be done without unleashing ambiguity-hell.

Even if we don’t have traits, we need a way of manually specifying low-level priorities instead of resorting to hacks like Hyperspecialize.jl. However, if we DO have traits, enabling something like this will be even more important.

A reasonable order would appear in situations where the sets of types subset so cleanly that they could have subtyped, like all AbstractArrays being iterable. The smaller issue is that there isn’t always an order to be universally applied. Something could be either indexable or iterable or both, and whether indexing or iteration is more important can depend on the method.

Common Lisp and Dylan, which influenced Julia, have both generic functions and multiple class inheritance. I honestly don’t know these languages well, and they seem much more complicated than Julia. But I think we can at least get a good sense of how ambiguity could be resolved and why it’s the bigger issue to me. This link has some easy(?)-to-read examples: Successful Lisp - Chapter 14. I’ll try to give a contained explanation, but the diagram in the middle inheritance section I’m adapting would help.

Say we had a generic function with methods for 2 supertypes we intend to reuse. Single argument so we don’t need to deal with multiple dispatch (very quickly, Lisp just uses leftmost arguments to break ties where we would normally get “ambiguous” MethodErrors).

abstract type C1 end
abstract type C2 end
m1(x::C1) = ...
m1(x::C2) = ...

One notable difference from the Lisp example is that we don’t have true classes, so we need to substitute abstract types for many of them:

abstract type C3<:C1 end
abstract type C4<:C2 end
abstract type C5<:(C3, C2) end # the nonexistent syntax for multiple supertypes
struct C6<:(C5, C1) end
struct C7<:(C4, C3) end

So the question becomes what does m1(C6()) and m1(C7()) dispatch to? If we follow the supertyping, both concrete types actually subtype both C1 and C2. Lisp resolves this with class precedence lists:

C6 => [C6 C5 C3 C1 C2 Any]
C7 => [C7 C4 C2 C3 C1 Any]

m1(C6()) would dispatch to m1(x::C1), which makes sense because C1 is directly named in its supertyping. m1(C7()) would dispatch to m1(x::C2), which we didn’t specify. In fact, it would take precedence over a m1(x::C3) method despite C3 being a named supertype! I might complain that C3 should be prioritized because I named it, but it’s just as reasonable to say I named C4 first so its supertype C2 should follow first. Either way, I’m at the mercy of the type graph’s emergent behaviors unless I resort to manual invoke, which kind of ruins the appeal of polymorphism and can become unjustified as the type graph expands.

Common Lisp’s precedence lists would influence C3 linearization, initially designed for Dylan but would make it into several more programming languages including Python. The C3 there means the order satisfies 3 correctness properties, but searching for a scheme that doesn’t blow up in our faces is a far cry from having real control over our code. People have said similar things about how multiple dispatch alone is a source of method ambiguities, but personally I found taming Python’s method resolution order to be far more limiting than designing method specificity in Julia. It isn’t surprising to me that multiple inheritance is heavily discouraged in languages that do have it, and other languages opted for alternatives with more limitations than similarities. I think coming up with good limitations like those in Holy traits is critical for solving our problems without falling into old ones.

3 Likes

Thanks for the writeup!

For me the main limitation of Holy traits is that Base only implements a handful, like IndexStyle. So if I can’t subtype due to the above limitations, I am stuck maintaining a list of forwards on all methods declared on that supertype.

BorrowChecker.jl could also be saved by Holy traits as an alternative to multiple inheritance, but it would require traits to be used in Base, which I doubt will happen. Stuff like:

#= in base =#
Base.NumberStyle(::Type{Float32}) = FloatStyle()

#= in my package =#
Base.NumberStyle(::Type{<:BorrowedMut{T}}) where {T} = NumberStyle(T)

Not complete of course but I think stuff like this would be necessary for getting around the current overload hell. It’s the same way IndexStyle is used, but for a broader spectrum of things rather than just iterables.

Not sure if it’s what you meant, but the problem discussed on that Rust page appears to be a namespacing issue rather than a dispatch ambiguity. The two get methods in the example are effectively separate functions with full names <Form as UsernameWidget>::get and <Form as AgeWidget>::get. Real dispatch ambiguities cannot be resolved by using “Fully Qualified Syntax”.

(I don’t know enough about Rust to know if there is any part of the language that is subject to dispatch ambiguities, but I suspect not.)

The analogous scenario in Julia would be one interface defined in ModuleA with a required method named foo and another interface defined in ModuleB with a required method named foo. It’s possible for a type in ModuleC to implement and use both interfaces, as long as we take care to handle namespacing issues.

You have to bear in mind that different languages don’t use the same names or one-to-one concepts. It’s ambiguous method dispatch in the sense that the normal call form.get() attempts to find a fitting method for the type of form and instead found 2. If trait namespaces encapsulating distinct functions disqualify that as dispatch, then Python class namespaces encapsulating distinct methods of the same name also don’t involve single-dispatched method calls (ambiguities resolved by C3 linearization). That could be a valid take, but it’s not a popular one. We could also consider multiple traits too different from multiple inheritance for comparison, but there’s no reason to be that rigid either.

To exercise some flexible thinking, invoke fully qualifies annotated types of the target method. Those aren’t modules and those types’ scopes don’t encapsulate the method, but that’s the job of generic functions. It’s only different in language design, not in purpose.

The fallback situation is very interesting because it’s suggesting several things, and multiple inheritance plus C3 linearization isn’t even the first thing that came to most people’s minds. Let’s make the situation literally more concrete, here is an order of types in the concatenated hierarchy (I may have flubbed it because I’m not certain about your intent, but hopefully it makes sense anyway):

struct BorrowedMut{T} <: Concat{AbstractBorrowed{T}, T}
makes the type precedence for T=Int:
BorrowedMut{Int}, AbstractBorrowed{Int}, Int, ..., Number, Any

So here are some possible designs

  1. Parametric multiple inheritance (well, supertyping) of (AbstractBorrowed{T}, T), you already explained it pretty well. The thorny issue is that just the T part is already an error in Julia. For one, subtyping a parameter has too much power (not sure if that’s the right word) to be implemented. For another, you’d make 2 concrete types subtype each other BorrowedMut{Int}<:Int, and it’s worse than the typical field inheritance examples because it violates the Liskov substitution principle: you can’t put BorrowedMut{Int} instances into a method body that strictly works on Int instances because you need to do the extra work of unwrapping. The earlier DynamicQuantities example deliberately avoided both these subtyping errors by each parametric type subtyping an abstract type bound.

  2. That nice line of types actually suggest single supertyping derived from the type precedence, e.g. BorrowedMut{Int}<:AbstractBorrowed{Int}<:Int<:.... That of course also subtypes a parameter and makes 2 concrete types subtype each other, but it’s interesting to consider that multiple supertypes might not have been necessary to pull this off.

  3. If we step back from the type hierarchy stuff and look at the fallback intent again, we need to do something like x.value for every function ever before forwarding, so in other words (foo::Function)(x::AbstractBorrowed{T}) where T = foo(x.value). A few more methods for arity and we’re good, right? Well, Julia disallows that broad of a type annotation in the callable position; after all, could we really put so many things into the method table for Function or Any? If you had a macro over the entire scope of your program, you could actually do a little more metaprogramming to replace calls with a higher order function call whose method table you can change. call(foo::Function, x::AbstractBorrowed{T}) where T = foo(x.value).

Good point! I guess the “proper” approach would be to have something like this instead:

struct BorrowedMut{T,A} <: Concat{AbstractBorrowed{T}, A}

where A would be an abstract type. Which means your constructor would look like this:

BorrowedMut(x::T) = new{T,supertype(T)}(x)

which would then give you:

\texttt{BorrowedMut{Int64,Signed}}\\ \hphantom{....}\succ \texttt{AbstractBorrowed{Int64}}\\ \hphantom{....}\succ \texttt{Signed}\\ \hphantom{....}\succ \texttt{Integer}\\ \hphantom{....}\ldots

without needing to require \texttt{AbstractBorrowed{Int64}} \succ \texttt{Signed} in an absolute sense.

At one point I kind of got something like this to work with Cassette.jl – but (1) Cassette.jl is now unmaintained and broken (:cry:), and (2) even if it did work again, it is a bit hacky and would also require users to wrap all their code with my macro.

Note, I would want to pass the wrapped type down through all calls, recursively, since the point is to flag unintended mutation