Feasibility of adding multiple inheritance to Julia

The topic of interfaces came up again in one of the correctness threads. My minimally informed opinion is that the best solution would be to add multiple inheritance to the language.

How feasible would it be to add multiple inheritance to Julia? I’m sure it would be quite a bit of work, but my question is not about the amount of work required. What I am wondering is how feasible it is to add multiple inheritance from a language design perspective.

3 Likes

Not an answer but relevant reading: https://github.com/JuliaLang/julia/issues/5

3 Likes

Yep, that’s linked in the original post.

that’s the smallest number of issue (#5) that are still open, by a lot. The next one is implement zeros() by calling calloc · Issue #130 · JuliaLang/julia · GitHub

1 Like

There was a Slack thread about this not too long ago, start here if interested. TL;DR it is possible to have multiple inheritance + multiple dispatch in the same language (as demonstrated by non-Julia languages), but subtyping in Julia is complicated enough that it’s not clear if/how they’d work together here.

2 Likes

Darn. Maybe I’ll have to finally sign up for Slack… :joy:

1 Like

It’s feasible, and has already been done in packages. If we just wanted multiple inheritance as fast as possible, we could have it tomorrow. That being said, rushing to have it out tomorrow is probably a bad idea–there are lots of kinks to work out.

AFAICT, it just hasn’t been a priority. It’d be great to have the devs say “Our plan is to get multiple inheritance and traits into the language by 1.X.”

I want to point out that both the linked slack thread as well as issue #5 are about abstract multiple inheritance; not OOP style concrete (multiple) inheritance (i.e., where you inherit fields from your parent), as OOP packages typically implement. I don’t think anyone thinking about traits is considering making concrete types subtypable.

11 Likes

Yeah, I’m interested in abstract multiple inheritance, not concrete multiple inheritance. From a cursory look at ObjectOriented.jl, it looks like it implements concrete multiple inheritance.

4 Likes

IIRC, ObjectOriented.jl implements both (with C3 MRO for method inheritance). I’m not 100% sure on the details, though (like how multiple inheritance interacts with multiple dispatch); @thautwarm will know more than me.

1 Like

ObjectOriented.jl implements inheritance with composition. Syntax sugars are just provided to do the tedious work.

Abstract multiple inheritance is actually implemented in ObjectOriented.jl as an internal utility, these are the underlying stuffs:

@oodef struct MyClass3 <: {MyClass1, MyClass2} end

# expands to 

struct var"MyClass1::trait" end  # utility type

struct MyClass3 <: Object{
        Union{var"MyClass1::trait", var"MyClass2::trait", var"MyClass3::trait"}}
     _base1::MyClass1
     _base2::MyClass2
end

# ... generated code for defining conventional OOP ops

Although we cannot directly define a julia type MyClass3 that subtypes MyClass1 and MyClass2, the information to construct the required type system is well encoded with:

Object{Union{
     traitof(Self), traitof(MyParent1), traitof(MyParent2), ...}}

This emulation can also do things that abstract multiple inheritance could do.

using ObjectOriented: @like, @oodef
@oodef struct MyClass1 end
@oodef struct MyClass2 end
@oodef struct MyClass3 <: {MyClass1, MyClass2} end
@oodef struct MyClass4 <: {MyClass1, MyClass2} end

test(x::@like(MyClass1)) = "method for class 1"
test(x::@like(MyClass4)) = "method for class 4"

test(MyClass3()) # => 1
test(MyClass4()) # => 4

Of course, this encoding of abstract multiple inheritance is not “native” to Julia. It needs compiler changes to be seamless integrated into Julia (e.g., no need for @like macro).

8 Likes

Besides, OOP is a just special trait called “Virtual Table Trait”.

If we do need some changes to Julia, I’d vote for enhancement to native trait notation in Julia instead of spending time on OOP stuffs.

15 Likes

Thank you so much for your explanation, this is amazing! :smile:

Oh, definitely. I’m not a big fan of OOP myself.

I think people have been talking about multiple inheritance more often than traits recently because it kills both birds with one stone–traits just happen to be a special case of multiple inheritance (just create a trait as a new “abstract type” and then let other types inherit from it).

I think the converse holds as well (you can simulate multiple inheritance with traits), but it’s a much bigger pain.

Funnily enough, though, @Lilith and I were discussing the same approach as ObjectOriented.jl for concrete inheritance with no performance penalty :sweat_smile:. Concrete types get their own supertype automatically (every struct X declaration creates a hidden parent type, abstract type AbstractX), and then whenever someone tries to subtype X, we just subtype the parent instead. (Thanks for the wonderful ideas, Taine :smile: )

The other language I use is Python (also what the aforementioned ObjectOriented.jl is based on), and it made me very pessimistic about multiple inheritance in Julia. Not because it’s impossible, but because automatic single-dispatch method resolution order (MRO) is mostly meaningless and it would make method ambiguities in multiple dispatch even more cryptic. Alternatively, I never want to manually write out the order or be blindsided by an error because an order can’t exist (first example in issue 5).

Python classes can have multiple parent classes, let’s annotate it in a Julia-style as C <: A, B. In the case where C lacks a method but both A and B have it, do we prioritize A or B? This becomes somewhat meaningless because C isn’t necessarily (and often isn’t) more A or more B, but we must choose. Python’s MRO is based on the order the superclasses are written, so the MRO here is C, A, B, object (object is roughly Any). In the more complicated case where A <: A1 and B <: B1, the MRO becomes C, A, A1, B, B1, object, arriving at mostly meaningless because while I wrote A before B, I certainly did not write A1 before B or B1. I may justify a CatDog <: Cat, Dog as more Cat than Dog, but not more Feliformia than Dog. Right after “prefer composition over inheritance”, people say “avoid multiple inheritance” for good reason.

In the simpler case before I gave A and B supertypes, the order is not different from a chain of supertypes C <: A <: B being used in Julia’s method dispatch with respect to an argument, so it risks the same method ambiguities as that case. But once we involve multiple depths of supertypes, we contend with automatic and meaningless MROs, and it’ll be so much harder to resolve method ambiguities without resorting to fully concretely typed arguments. Holy traits are great because there is a method for each context, and each context can have single supertyping. So I don’t have to say CatDog is more Cat or more Dog, I can check FeliformiaSpecies(::CatDog) and find it is a Cat.

1 Like

With Holy Traits, you’re doing that same method ordering by choosing the order you check your traits in with the if/else clauses of that method; the issue there is that you can’t add a new supertype to your type without adjusting all places where you’ve previously added such an if/else discriminator.

In my view, this sort of ambiguity is unavoidable, but perfectly predictable. At the time you’re declaring that your type C subtypes A and B, A and B should already exist, so you should also already know about any potential ambiguities in dispatch that can come up when you use C; hence, those ambiguities can & need to be handled for C explicitly already. I also think that if both A and B have an egregious ambiguity in the first place, how can it make sense to define a C that ought to behave like both A and B in the first place? Clearly, that must lead to faulty behavior in those overlapping meanings, no?

5 Likes

I avoid handling multiple contexts in a method for these reasons.

Right, that’s why multiple inheritance is discouraged. The only way to fix the ambiguity is to order them C <: A, B, but that order often doesn’t make sense. There may not even be 1 canonical order we should put in the definition; different trait-checking branches could have different orders.

So, Holy traits don’t replace multiple inheritance to me, it still plays by single-subtyping rules, just in several different contexts and derived from the primary single-supertyping hierarchy. It does provide some of the polymorphism expected from multiple inheritance, though that can also be said of composition, which is suggested over any kind of inheritance. Taking my earlier example, CatDog could just be a composite of Cat and Dog, and I dispatch on the fields.

Not necessarily; you can also define a function taking C explicitly, where you can then define whichever order you prefer for your C. Having multiple abstract inheritance in the language just points that issue out to you immediately, while also allowing you to resolve it cleanly for your context, without having to touch existing functions thanks to multiple dispatch.

You can’t always do that composition though, if you don’t have actual Cat and Dog objects to use for that composition; e.g. if the two are just abstract types!

We could, but that manual case-by-case effort seems to defeat the purpose of supertyping and inheritance to me. If the order isn’t tied to the subtype, the simplest way I can imagine what you described is putting the order right in the argument, like foo(c::(C <: A, B)); the set of supertypes must be tied to the type, or else you could arbitrarily attach supertypes. I can see how that would help if it calls bar(c) to dispatch to the methods bar(::A) or bar(::B). But there’s a couple issues:

  1. I don’t want to be stuck annotating an argument concretely; if I make another subtype C2, I don’t want to copy methods or resort to metaprogramming. I would instead have methods foo(::A) and foo(::B) and the dispatch system should figure out foo(C()) and foo(C2()). The only way to do that is put the order in the type definitions.
  2. The method ambiguity complexity skyrockets with case-by-case order. Even without involving another subtype, foo2(c1::(C <: A, B), c2::(C <: B, A)) = bar(c1) + bar(c2) is incredibly unintuitive. At that point, it’s a lot more sensible to use separate functions.

I don’t encounter situations where I need to emulate composition for abstract types that can’t be instantiated; it’s worth refactoring concrete components if it calls for it, especially an example like CatDog. If there aren’t discrete components, then it’s more a case for traits as needed. Multiple inheritance isn’t very useful in OO languages, and newer languages with prominent interfaces are eschewing inheritance altogether.

I think the problem with saying “I don’t like this, but I like traits” is that traits as currently conceptualized have many of the same issues with complexity and ambiguity around dispatch. In that sense, abstract multiple inheritance is one idea for a trait system. If it helps, think of it as multiple interface conformance instead—this is not the usual concrete or abstract base class multiple inheritance you see in modern OO languages, and I think mixing the two concepts will only lead to confusion.

8 Likes