Feasibility of adding multiple inheritance to Julia

Multiple inheritance always turned out to be difficult. The MRO used by Python at least adheres to some consistency conditions fixing problems arising in Common Lisp (this should be the relevant paper).
Seems that multiple inheritance works better when used for behaviours alone, e.g., via interfaces/protocols or type classes. In this case, “inheritance” merely states that a class implements the functionality required in some interface. As long as methods in interfaces have different names there will never be any problem, i.e., the order of inheritance does not matter. Also hierarchical relationships between interfaces simply describe that something that wants to implement that particular interface must also implement all of its super-interfaces.
Imho, the latter also makes more sense in Julia, as concrete types cannot be subtyped and methods cannot be extended, i.e., there is no call-next-method for super calls or the like. On the other hand, Julia is sufficiently different to other languages in that every function is generic and methods can be defined for any combination of abstract as well as concrete types. Compared to that e.g., type classes in Haskell are rather restricted in that functions are either polymorphic, i.e., have a single implementation with types possibly restricted to some type class, or are part of a type class and therefore need to be implemented fully and separately for any type that implements (inherits from) that type class.
Not sure what would be the best way forward, but full multiple inheritance – as in OOP languages – does not seem like a good fit for Julia.

8 Likes

AFAIK other languages can avoid compilation errors with overlapping interfaces or traits with a restriction e.g. identical return type and method signatures sharing 1 implementation, or with manual disambiguation. Julia’s method signatures aren’t as restricted (especially the return type), so I’m not sure if that is adoptable.

While not inheriting data is a step in the right direction, I’m not sure how multiple dispatch can be incorporated elegantly. Julia’s informal interfaces or Holy traits aren’t inherently tied to 1 type. You can derive a trait from >1 arguments (SimpleTraits.jl accounts for this), e.g. Ischased can be derived from (Cat, Dog) or (Mouse, Cat), but not any of them alone because there’s almost always a bigger animal to chase them. While interfaces often vary on 1 type and fix the types of the other arguments, an interface can be a set of methods around 2 interacting types. Whatever is suitable for Julia will probably need to look very different from C <: A, B.

1 Like

Should be easy enough to namespace them (i.e. Interface.method(x) calls method, whenever multiple interfaces provide method.)

Yes, those are important and good points:

  1. Method signatures are indeed more general in Julia than most other languages, i.e., not being restricted to single types or same number of arguments.
    Further, methods are also extensible in ways the original author of the API did never think about which is often not possible in other languages, e.g., parametric functions with type class (Haskell) or trait bounds (Rust) are not extensible at all.
  2. Multiple dispatch also adds additional challenges. While Common Lisp has both, multiple inheritance and multiple dispatch, generic functions are not the only option and used much more sparingly (they are also restricted in that all methods require congruent lambda lists).
    Also multi-parameter type classes are available in Haskell, but most code seems to gravitate to other extensions like functional dependencies or type families which can often achieve similar effects.

All of this makes it harder in Julia to come up with meaningfully reusable abstractions as more variants of an idea can be captured within a single generic function. Guess there is just little prior art in this area of the design space and we need to come up with own best practices on how to employ multiple dispatch. On the other hand, there are already some quite impressive example of how composable code can be due to pervasive multiple dispatch. Currently, it seems like (the usual?) trade-off between flexibility and (correctness) guaranties … I certainly enjoy the flexibility :slight_smile:

2 Likes

Yes, that would probably work … similarly how trait systems in other languages require you to explicitly disambiguate overlapping functions from several traits.

In my honest (and maybe deranged) opinion, inheritance should have never existed. Not even the single inheritance with all concrete types being final like Julia have, which is the best between all types of inheritance in my opinion, but it is still better to have none than it. Only interfaces. Multiple dispatch should work only over concrete types and (maybe) these interface types. But then some rule is necessary to decide between f(x::A) and f(x::B) when typeof(x) <: A && typeof(x) <: B). Because not being able to add a new interface to an existing object because it would make other calls ambiguous is too restricting. The call must resolve, even if by choosing a random interface between the ones available.

2 Likes

Multiple inheritance is the biggest “SNAFU” in C++, IMHO, and its apparent magic often ends up causing nasty bugs and unexplained behavior – just when you need them least!

I come from a long C++ background and one reason I moved to C# was to put the multiple inheritance nightmares behind me. The compromise found by C# is good, slight of forfeiting type abstraction (inheritance) altogether as proposed by Henrique_Becker (who’s logic is spot on): single inheritance of an implementation is allowed, and multiple inheritance of interfaces. This works quite well, without the nightmares. So for what it’s worth: I wouldn’t go down that road.

4 Likes

Why not? It seems to work perfectly for number types at least?

Maybe I’m missing something, not up to speed on traits. I want to have Reals and e.g. +, -, *, ^, sqrt, cbrt, fourthroot, exp, ln (more, except for log variants?), just work.
How do interfaces (or traits) help with that? I’m not sure but the model you propose, only interfaces, is that the Go model?

I think it’s great to be possible to restrict to e.g. Reals in methods, but I agree you should only specify concrete types in structs. At least non-concrete, e.g. Real is a performance-trap there, is that what you meant? Possibly it shouldn’t have been this easy, but tools can help? Flag Abstract* plus the few other non-concrete known types, and no type/Any used. It’s too late to ban it, though possibly it was thought to be a very nice option from the start.

I’ve not followed the discussion on multiple inheritance, but yes it’s toxic, if it’s inheritance of behavior, while at least or plausibly ok for of interfaces/abstract types.

1 Like

I propose only interfaces, yes. I do not know how Go does it. I am looking more at Haskell typeclasses when I say that. You can have your inheritance, in the sense that the Ord typeclass (which defines ordering) inherits the = of the Eq typeclass. However, different from what is often recognized as inheritance (even in Julia), a concrete type can implement any typeclasses a posteriori. So you are not locked in an hierarchy defined by others. Haskell has a similar hierarchy for numbers like Julia have but this is not done by means of inheritance (and the concrete number types all need to inherit a single abstract number class that it is the most adequate for them) but anyone can implement a Real interface for any type a posteriori.

2 Likes

I don’t think the problem is single inheritance. It’s that the only formal tool we have for enforcing an interface at all right now is single inheritance. Shallow type hierarchies are a good way to annotate a very explicit system. But without the ability to limit further subtyping at a certain point in compilation, ensure certain data fields are present in all subtypes, or enforce some method support, one is left with a bunch of ambiguities and method invalidations.

You can get the compiled behavior you want from much of this currently if you switch from Sunny <: Weather, Rainy <: Weather to Weather = Union{Sunny, Rainy}. Then you lose out on some of the self-documentation and reflection methods you get from subtyping.

Although I think approaching things with a more functionality based design sounds promising, I anticipate similar difficulties. The struct SubType <: SuperType end syntax is very intuitive syntax for inheritance that can be closely bound to its compilation. The parametric information is easy and intuitive to enforce without requiring the compiler to do to much heavy lifting. If we are requiring methods be defined, how do we keep all that info syntactically close together and lower it so it’s quick and easy to verify? This is a dynamic language, so if we are going to add automatic compilation and checks it better be relatively quick. The same goes for multiple inheritance. Just because we can find a way of making human interpretable rules for dispatch doesn’t mean it will translate to good code. Dynamic look up of inheritance across multiple classes sounds like a way to torpedo compilation times.

2 Likes

In Go, you don’t even declare that a type implements an interface, all you do is define methods for the type that match the method signatures listed in the interface definition. A type can implement multiple interfaces. But if there’s no formal statement relating a type rect and an interface geometry, how does the call measure(r) dispatch to the right function func measure(g geometry)? Simple, there is no function overloading, so dispatch only needs to consider the function/method name and the method’s type, and the compiler checks if a type has all the interfaces’ methods. Since Julia has multimethods that vary by argument type constraints, Julia cannot emulate Go’s interfaces.

2 Likes

I’ve not done much with Go, but it seems like their “interface” syntax is a lot like Rust’s traits that couple a set of methods together. I while ago I mentioned an idea on Zulip that might be similar. It would look something like

struct Geometry{A, P} # instead of Go's `type geometry interface`
    area::A
    perim::P
end

struct Rectangle
    width::Float64
    height::Float64
end

function Geometry(r::Rectangle)
    area(r::Rectangle) = r.width * r.height
    perim(r::Rectangle) = 2*r.width + 2*r.height
    Geometry(area, perim)
end

measure(geom) = _measure(geom, Geometry(geom))
function _measure(geom, interface::Geometry)
    println(geom)
    println(interface.area(geom))
    println(interface.perim(geom))
end

There is some ugliness to this approach but if we had a way of enforcing measure(geom) doesn’t get explicitly overwritten then you have the beginnings of a pretty well controlled interface.

1 Like

I was not asking how Go does it, nor proposing that Julia adopts their way. I was just making clear to Palli that my inspiration was Haskell, and I fail to see why the Haskell way would be impossible in Julia.

3 Likes

We do have a Number class that’s more general than Real, since it includes also Complex. But only having Number is not good enough. Real defines all the math and ordering operations, but you can’t compare complex numbers, so you don’t want that required for complex numbers as part of the interface, so what does Haskell, Go and other languages with interfaces do? It seems nice to have a hierarchy of abstract types for e.g. that reason.

So you must do that for each Real type of number, not just once generally? Go used to not have generics, but that changed I believe, though.

For numbers it seems good to be locked into this hierarchy, as it’s sufficient?

I haven’t been using nearly as long as many here and I tend to write pretty specific types of numerical code. So I’m usually hesitant to comment on Julia language issues. But I do have opinions on Haskell type classes.

There is a diagram of the hierarchy of Haskell type classes in the the Haskell 2010 language report. The issue with ordering is addressed nicely. But there has been a lot of argument about the hierarchy and I think there are some decisions that are widely viewed as mistakes. My own biggest complaint is that Haskell doesn’t have a type class for complex numbers that includes conjugation and therefore doesn’t provide a way to view real types as subsets of the complex numbers that can be conjugated. Instead the conjugation function has a more specific type and only works on a more specific complex data type that includes real and imaginary parts. This makes it a giant pain to write functions that work with the set of complex numbers in general, with the reals being a subset for which conjugation does nothing. I always immediately defined my own complex type class that includes my own conjugation function and made both real and complex types into instances. There is also a restriction that a type Complex a needs to have a as an instance of RealFrac. So the current situation also gets in the way of other subsets of the complex numbers like the Gaussian integers.

The Haskell prelude where all this is defined can be swapped out for something else and there have been attempts at alternative numeric type class hierarchies. But trying to cover every possibility results in something that is very complicated.

I do miss having clearly defined, inferred, and statically checked interfaces. That’s the beautiful part of Haskell. But trying to get anywhere near to the flexibility of Julia using type classes is hard. You can either oversimplify (like Haskell) or go all in and create a monster hierarchy. And there are many opportunities for mistakes either way.

3 Likes

I know, but it was good to rule out emulating Go’s interfaces and name the reason (requires absence of function overloading or multimethods).

If this means implementing each numeric type for a Real interface, yes. This isn’t unlike Julia; you need to implement an ideally small set of informal interface methods for the custom subtypes of the interface’s abstract types to allow the generic methods that work on the abstract types to also work for the custom subtypes. (Abstract supertypes need not be special; the iterable interface has iter::Any.)

Go does have generics, just not anything like multimethods. As polymorphic as it is, 1 generic method is still only 1 method.

4 Likes

It is certainly insightful to look at other languages and especially numerical hierarchies are difficult to get right. Imho, the real stress-test is numerical algorithms, e.g., as in LinearAlgebra with lot’s of different types and opportunities for optimization for specific combinations.
Further, we need to consider that Julia is sufficiently different from most other languages. E.g., Haskell, Rust and Go (?, not entirely sure here) distinguish between types classes/traits/interfaces and parametric/generic functions:

  • Type classes/traits/interfaces define sets of operations which can be (and need to be) implemented by different types. In particular, they allow for different implementations of methods with the same name.
  • Parametric/generic functions can be used with different types – possibly restricted to some type class/trait/interface. Importantly, a generic function has a single implementation which nevertheless works with different types (it’s purely a performance optimization when the function is compiled separately for different instantiations).

In contrast, in Julia every function is polymorphic and can be defined differently for different types. Further, multiple dispatch allows for polymorphism in several arguments.
As an example, consider a generic inner product inner(v, A, w) = dot(v, A*w). Now, if dot and * are from some type class/trait/interface they can be specialized on different types such as v::OneHotVector. Yet, in most languages there are two challenges:
First, depending on how * is defined, the interface might not be owned by the type of the vector and maybe cannot be specialized for OneHotVector?s, i.e., single vs multiple dispatch.
Second, while the generic function inner works on different types (implementing the proper interfaces) it cannot be specialized itself, e.g., when optimizing for specific type combinations.
In Julia its is trivial to just add a method inner(v::OneHotVector, A::AbstractMatrix, w::OneHotVector) = A[v.ind, w.ind]. Imho, on the one hand, this flexibility is part of what makes Julia code so composable. On the other hand, it makes it harder to define what its interfaces are, i.e., what can be said about/expected from inner? Is it part of an interface (and supposed to be extended) or merely generic (and simply a short-hand for dot(v, A * w))?

1 Like