Is Julia's way of OOP superior to C++/Python? Why Julia doesn't use class-based OOP?

With Holy Traits, the rule is more strict that normal for type piracy because you don’t want to constrain the type of the argument that would actually receive your types initially.

For instance, suppose I defined a collection of types which share a trait MyTrait but no common supertype. I cannot add methods to Base.show for this trait because that function doesn’t have a trait dispatcher already built into it.

Defining

Base.show(io::IO, x) = show(HasMyTrait(x), io, x)
Base.show(::MyTrait, ::IO, x) = ...

is type piracy becasue of the first method. This is the only real problem for me with Holy Traits: you need to fully own a function to add methods to it.

7 Likes

I thought my example was clear. You don’t want to mess up with foo. What you really want is define your own subtype of foo where coeff is sorted. But the assumption is you cannot do that because you would need your type to inherit from two abstract types (just like IndexStyle has
to be a trait for AbstractArrays). I could just in my code taken the example of IndexStyle and
shown how the code would be simpler using Interface as a language feature.

Mason: yes 100% agree on what you say about stricter rule for Traits. Another reason why Interfaces would be better.

1 Like

Jean, Your example of CoeffSortedStyle seems a bit over my head. Do you mind providing more background? - Clark

I thought it was straightforward so I do not know where to start. Do you have a problem with the Holy trait pattern, or with the particular context I choose?

I have myself experienced the sort of “magic” that multiple dispatch + proper generic code provides, making me forget any yearning for class inheritance or other OOP features.

I had created a type for a data structure that contains kinematic data for certain moving objects, and – besides other functions specifically designed for that type – I also defined methods that extended the sum of two of those objects (+(::MyType, ::MyType)), their multiplication by another value (only meant to work with scalar numbers, but I didn’t specify the type of the other factor), and zero(::Type{MyType}). And then one day I discovered that I could use some functions from DSP.jl, such that for instance I could apply a FIR filter to vectors of those objects, without having to write any line of extra code!

In practice, my type had “inherited” a property of other types of time series, of being apt to be filtered, even without any knowledge of what is happening under the hood of DSP.jl. Glorious! And I’m sure that they have also “inherited” other properties of yet unknown packages that do other algebraic operations, but I don’t need to make my type child of any new parents to achieve that.

Nevertheless, the fact of having achieved this so unexpectedly is not wholly comforting, since it makes me feel that it was kind of fortuitous, and having my programs working by happy chance is not what I really want.

More generally, I think that Julia’s capacity to provide this kind of experiences is not as appreciated as it could be, because in many cases the interface that developers should take into account to make this happen is not clear. As discussed in this older post, someone with OOP background who wants to extend the functionality of some type A, and tries to make a composite type B containing A, may feel intimidated by the many methods on A that should (seemingly) be extended, even with tools like Lazy.@forward that would facilitate that labor.

As mentioned in that discussion, abstract types with a well-defined interface can make that easy, and help to perceive the advantages of type composition + generic code + multiple dispatch. There are some good examples mentioned there, and that’s a practice that should be encouraged and promoted.

6 Likes

No, sorry. Probably my bad, but I lost track of which type is abstract and what is outside the programmer’s control (ie in another package you cannot touch). An MWE would help.

OK, so I guess I need to repeat my example. Perhaps not the best example but I won’t change so as not to introduce even more perturbation.

Suppose you want to work with a hierarchy of objects (say abstract type foo and abstract subtypes foo1 and foo2) which implement a method for ‘+’ (not your work so you don’t own these classes and subclasses). You notice some objects in foo1 (not all) and some objects in foo2 could use a more efficient algorithm for + since the field coeff which needs to be sorted during addition is already sorted in them.
To solve such a problem by composition, you would make a new type which has a field coeff which is sorted This is not possible here since the field coeff is owned by the hierarchy and you cannot hijack it.

A natural solution to the problem would be an Interface based on the propriety: coeff is sorted.
So let’s try to make one with a trait. To express it as a trait you do

abstract type CoeffSortedStyle end
struct CoeffSorted<:CoeffSortedStyle end
struct CoeffNotSorted<:CoeffSortedStyle end

Then comes the bad step: you need to make your supertype foo aware that there is a trait below it (of which there is no logical need — it is just the implementation of traits which forces you to do that):

CoeffSortedStyle(X::foo)=CoeffNotSorted
Base.:+(x::foo,y::foo)=+(::CoeffSortedStyle(x),x,y)

And, as observed by Mason, you cannot do this if you have no ownership of foo.
Only then you can define what you really want:

Base.:+(::CoeffSorted,x::foo,y::foo)=# routine which takes in account that coeff is already sorted

In a language with interfaces, you would do something like

Interface CoeffSorted end
Base.:+(x::{foo,CoeffSorted},y::{foo,CoeffSorted})=# routine which takes in account coeff is sorted

and that’s all. No need to interfere with addition for foo or subtypes of foo . I do not think any clever macros can bring us there without some change to the language. But I would be happy to be contradicted on that.

6 Likes

Can this be solved with Base.:+(::CoeffNotSorted, x::foo, y::foo) = invoke(+, Tuple{foo, foo}, x, y) ?

I do not know if it is your code or my mind which goes in an infinite loop but one of them does…

Well, maybe I just mistunderstood you problem originally. Effectively, you want to override + behaviour for some values of types foo1 and foo2, right? Then it makes sense this is not allowed without type piracy (your +(foo, foo) method) because what you want is piracy by itself.

In this particular example, I would replace CoeffSortedStyle with sorted_coefs, a function that returns the sorted coefficients.

1 Like

One of the challenges with describing a new feature is that the proposer necessarily relies upon simplified problem description(s) to get their point across without burdening readers with complexity. However, by doing this, they open themselves up to critiques which refute the simplified example, rather than discussing the general pattern. It’s a hard challenge to see the world differently. Anyway, I still don’t grok the issue… but, perhaps it’ll emerge. Perhaps it’s worthy of a PR that’s focused on describing it in a “pattern” style, that is the challenge faced, the forces that guide/restrict solutions, various options with their positive/negatives, which includes both existing work-around and the proposed improvement. The existence of a workaround for a set of cases shouldn’t negate the proposal though. I mean, anyone could implement object oriented programming by adding to each structure a pointer to a function table, or refute object oriented by showing how an enum + switch covers 80% of the cases (and the other 20% are rejected as being “so clever, you’d never want to do it in a production system”).

6 Likes

Sure, and “maybe there’s a slower algorithm that works with unsorted coefs, but is faster than sorting the coefs”.

This actually reminds me of the earlier comment:

Putting the challenges of fair benchmarks aside, the reason speed is emphasizes isn’t because it is the ultimate advantage, but because it’s the most concrete, the easiest to understand and explain. The easiest to map towards past experience: we’ve all waited on code to finish running, but we haven’t all built a large project following the design principles and idioms of different languages to see their strengths and weaknesses. And new comers to Julia in particular don’t have those experiences within Julia itself.
So, how can these advantages and principles be conveyed?

I actually agree with @Jean_Michel’s gripe. It’s a problem I’ve had recent in VectorizationBase.jl, which defines AbstractSIMD{W,T} <: Real, which represents SIMD elements that contain W instances of T.
These are supposed to act like single elements rather than collections, so aside from not supporting branching, they try to mimic a T.
However, this breaks down because for Vec{4,Int} <: AbstractSIMD{4,Int}, unfortunately Vec{4,Int} <: Integer and Vec{4,Int} <: Signed return false, and Vec{4,Float64} <: AbstractFloat returns false as well.

Makes me wonder if I should instead define many separate struct types:
SignedVec <: Signed, UnsignedVec <: Unsigned, FloatVec <: AbstractFloat for better compatibility with user code.
But Vecs aren’t the only type, there is also a VecUnroll type used for representing a set of unrolled vectors; these must now also be replicated for each separate instance that they’re supposed to fit in.

Then I’d need to define

const Vec{W,T} = Union{SignedVec{W,T},UnsignedVec{W,T},FloatVec{W,T}}
const VecUnroll{N,W,T,V} = Union{SignedVecUnroll{W,T},UnsignedVecUnroll{W,T},FloatVecUnroll{W,T}}

for conveniently using these in generic method signatures myself, with of course the note that this isn’t easy to extend.

Maybe I should do this, but it certainly is messy, and definitely looks like a symptom of sub-optimal design.
In contrast, I currently have just the Vec and VecUnroll, but need to deliberately extend any functions with specific ::Integer type signatures. Beyond method signatures, sometimes packages, like MultiFloats, provide restrictions on the types of their fields.

At the moment, I am seriously contemplating making this switch.

Traits are better than these type hierarchies because they’re orthogonal, which makes them more composable. But type hierarchies are still pervasive throughout code, and I confess to still making a lot of use of themself, because they – the worse solution in effect – are better in terms of convenience and ease.

10 Likes

This is a nice example @Elrod. This is one of the situations where it’d be quite handy to be able to make Foo{T} <: T. The other ones I’m aware of off the top of my head are

  • Symbolic types: when making a symbolic programming system that models julia code, it’d be really nice if you could have say Symbolic{Integer} <: Integer and Symbolic{DenseArray} <: DenseArray.
  • Autodiff: in automatic differentiation via operator overloading, you want a Dual{T} <: T so that you make sure you go down the right code-pathways for e.g. Real versus Complex primals.

A trait based approach would be quite nice for this.

6 Likes

I asked for an MWE since it is not clear what “classes” are in Julia. Abstract types? With what part of the API exposed? Or are foo1 and foo2 concrete? [Also, using standard Julia conventions (Foo etc) would help, but are not essential.]

Also, it is not clear to me why a package that owns neither the foo types (foo, foo1, foo2) nor + should be defining methods for them. Perhaps I misunderstood what’s where. In any case, I reiterate that an MWE would really help, eg along the lines

module WeDontOwnA
export Foo
abstract type Foo end
end
...
1 Like

Sorry. foo, foo1 and foo2 are of course abstract types. I fixed my post.
My actual MWE came while trying to port to Julia the Chevie GAP package. It is full of mathematics (Coxeter groups, complex reflections groups, Hecke algebras) so I tried to transpose.
As for not-ownership, it is not uncommon for a package to be there just to improve the performance of another one. In my actual case, I have ownership, but it is a maintenance problem that all classes must be aware of traits below them.

2 Likes

On the specific issue of speed, I believe it’s worth advertising that Julia shines relative to Python + Numpy, etc. in the many, even typical, cases where we’re mixing looping and complex logic. I like the examples given here and the quote “However, to take full advantage of Numpy functions, you have to think in terms of vectorizing your code. And it is not easy at all to write complex logic in a program in the form vectorized code all the time. Therefore, the speed comparison with Julia should be done for situations where somewhat complex logic is applied to an array for some kind of processing.” Maybe someone can suggest a universal-enough example to be included as a benchmark…

4 Likes

That’d be a cool feature for all the times I made a New{T} heavily based on an existing type T and just wanted the existing methods for T to just work on New{T}. But it seems like a source of dispatch ambiguities: f( New(0) ) couldn’t pick between the methods f(::Integer) and f(::New). I’m imagining that the only way this can be avoided is if we somehow promise f(::New) won’t exist, that we designate New{T} as an imposter type of T that borrows all its methods except critical Base methods like (+).

I hope I’m not unfairly picking quotes out of context but in arguing the benefits of multiple dispatch I thought that there was a contradiction in

and from @oxinabox’s JuliaLang: The Ingredients for a Composable Programming Language is

“Name collisions make package authors come together and create base packages (like StatsBase ) and agree on what the functions mean.”

Happy new year to all and thanks for being such a supportive and stimulating community.

No that’s a misunderstanding. Stefan is saying that it’s possible for people to extend someone’s code without them knowing. What Lyndon is then saying is that people tend to actually do that (people actually do extend each other’s code), and then there starts to be more and more functionality that gets shared between packages, and so then people tend to make Base packages so that way different parts of the ecosystem can depend on each other without having to depend on each other.

For example StatsBase.sample is used all over the place, but the authors of Distributions.jl probably don’t know how Turing.jl has changed StatsBase.sample. It all works together though. This function is then in a Base package so that way anyone who wants to extend sample doesn’t need to have a big dependency like Turing.jl or Distributions.jl, they can have a simpler more direct dependency that just defines some simple statistics functions. Another great example of this is Tables.jl which defines the table interface that everyone extends. Tables.jl doesn’t know all of the table packages, nor does it need to, but they all work together by extending this common base. An example of a table that would never want to inherit from a table class is the ODESolution.

So the existence of Base packages is really showing that there are very deep connections throughout the ecosystem, so much that without Base packages llike ArrayInterface.jl you’d have that every package depends on DifferentialEquations.jl, Turing.jl, DataFrames.jl, etc. which would just be an insane dependency tree. Instead, there are small little packages for “here’s the names we agree on, go hog wild!” which then have thousands of packages extending the interface.

11 Likes