RFC: Language Support for Traits — Yay or Nay?

It’s something we want to be okay, but is not okay with Holy traits unless someone modifies Base.

The reason is that with Holy Traits id have to write something like

Base.show(io::IO, x) = Base.show(io, has_my_trait(x), x) # <—— Piracy

In order to do the dispatch step I want which is

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

That is: with Holy Traits, in order to dispatch on the trait you first need to add an intermediate step that involves adding methods with types you don’t own. Therefore, you must own the function to enable trait dispatch.

5 Likes

I take your point. Do you take mine? If you were to go about being the brave soul who does the steps in RFC: Language Support for Traits — Yay or Nay? - #3 by tim.holy, how would you do it? First, you’d create a package called BaseRewrite that overwrites lots of methods in Base to make them traitful. Then you’d create branches of a bunch of packages to depend on BaseRewrite and demonstrate that voila, the world is suddenly full of unicorns.

The main point is that development in Base itself is hard: it’s much easier to prototype in a package and then move it into Base. I haven’t followed this area carefully since the early days of my proposal, but I’m not aware that anyone has written BaseRewrite and shown how much better life gets.

5 Likes

I’ve yet to see a proposal for formalizing traits that saves more than three lines of code with reasonable logic.

2 Likes

It’s my impression that traits are a handy feature which Julia is nonetheless unfortunately not super well-equipped to support

I’ve looked at rust traits a lot lately and tbh I was surprised by how little there was there despite all the talk The best thing I saw was “derive” for traits but that has some serious limits depending on the kind of trait being implemented.

My point isn’t to put down any effort, but point out that there has to be something more than some nice shorthand that is solved. I’m not convinced that overcoming some inconvenience in dispatch patterns or inheritance is ever going to be sufficient incentive. If you could somehow overcome type instabilities, invalidations, or some other existing issue then you’d be on to something

3 Likes

Oops, for some reason I deleted this from my OP: I think also traits might have to be a special function, in that traits is not called to dispatch on the traits of its argument (to avoid stackoverflow).

It could be similar to that, or new keywords could be reserved; I just wanted to express the idea in terms of existing Julia as much as possible for clarity.

To make this idea “work” as a package—that is to say, to hack the idea so that on a surface syntax level it works with the current dispatch system—I’d make a macro which would wrap function arguments such that whenever calling a function,

f(a, b, c, d)
# is transformed to
f(TTr(a), TTr(b), TTr(c), TTr(d))

where TTr is defined as:

struct TTr{X, XTr}  
    x::X
    TTr(x::X) where X = new{X, traits(X)}(x)  
end

Then, method signatures would be transformed like this:

f(a, b::B, c::::CTr, d::D::DTr) = ...
# is transformed to
f(a::TTr{<:Any, >:Union{}}, b::TTr{<:B, >:Union{}}, c::TTr{<:Any, >:CTr}, d::TTr{<:D, >:DTr}) = ...

It should work quite handsomely with methods defined and called like this.

However, you’re confronted with the fact that this would only work for functions that have already been declared this way; nothing in Base and no other package would be compatible with this function call, so you’d have to know at the callsite not to use this macro on those. It would be an un-composable package, an island of its own. Unless…

Yikes!

I believe this proposal, implemented in the compiler, isn’t breaking (at least if we consider it okay to break code which uses the pattern var::Type1::Type2, which I’d argue is already broken). Code that already implements Holy traits would still work; it just wouldn’t be necessary to do it that way anymore. And code that doesn’t use any traits would work as-is.

Implementing it as a package, however, would require breaking literally everything and a herculean effort to make it work again.

Yes, this is the sort of insight I’m hoping to gain here. What are the most onerous and insidious possibilities you can imagine this proposal would open? Would they be infeasible to address through proper patterns and use of Test.detect_ambiguities? Would we need new tools to understand the interactions? Would such problems be solvable, or would it be a Pandora’s box?

Possibly, but I feel like that problem is better solved by adding better support for wrapper structs. My ideas on that topic aren’t mature enough to be worth sharing though.

This is exactly what inverting the meaning does: when you query whether an object implements a set of traits, increasing the size of the query set decreases the size of the set of objects that match the criterion, since the latter set is an intersection. An object that implements a very large set of traits is a special object, as the set of such objects is very small.

Being able to dispatch on both type hierarchy and traits allows for further intersection.

This is an excellent example of how this proposal would allow greater extensibility. This is probably also the best example of why this idea could open up unexpected dispatch ambiguities, because every object must show :sweat_smile: If this problem is resolvable, then perhaps any such problem would be.

For example, suppose I define MyTrait2 and show(::IO, ::::MyTrait2), and I have an object ::Foo for which traits(Foo) >: Union{MyTrait, MyTrait2}. This creates an ambiguity for ::Foo’s show method, unless we either define show(::IO, ::::Union{MyTrait, MyTrait2}), or we define show(::IO, ::Foo).

Is it possible to develop a set of rules—language features, design patterns, best practices, etc.—by which such an ambiguity could readily either a) be anticipated and avoided, or b) be detected and resolved? Perhaps we’d want some friction in the process of adding traits to a type, because adding a new trait can suddenly introduce dispatch ambiguities? Or maybe we’d want type hierarchy visualization tools? Or maybe we’d want language features to enforce orthogonality of traits? What would trait orthogonality even mean? I’m spitballing here and wide open to ideas.

It seems like maybe traits should continue to be considered an “advanced” feature, a tool with sharp edges that neophytes would be discouraged from using unless there are ways to put safeties in place. I suspect that the extra :: should also add friction to cause a preference toward dispatching on the type hierarchy instead of traits, but maybe I’m wrong.

As @Mason points out, this isn’t just about saving three lines of code. Functions that don’t already dispatch on traits, cannot be extended to. And functions that do, can’t be extended to specialize on new trait categories (groupings of traits that are returned by a GetThisTrait function). By contrast, this proposal makes such trait categories unnecessary.

That curse is also a blessing: it forces trait dispatch to be rather specialized in what trait categories it can dispatch on, which alleviates the potential for ambiguities (the ambiguities for the show method example above are impossible with Holy traits). But it makes further specialization impossible.

Can I interest you in type-parameterizing Core.Box? As shown here, that could result in immediate performance improvements. I also have ideas for how to reduce the number of boxes that are generated, but perhaps that should be for a different post (infact, I already pondered a simple one here that garnered zero attention, and this one only became an argument. I’m also hoping to build support for this one.).

Honest question: are traits that useful? Do we have some nice examples of “killer application” like we do for multiple dispatch?

I understand the interest, but I also value the language being “simple” instead of implementing a bunch of “potentially useful” features and becoming loaded with footguns like Scala and C++

5 Likes

For me traits are a limited feature which provides a lot of the benefits that would come if it was possible for a concrete type to inherit from several abstract types (not inherit only from a single one as is the case in Julia). There are a lot of useful patterns available in languages where it is possible to inherit from several abstract types. Having traits in Julia helps a lot in translating such patterns. And I think also this is a good way to think and get intuition about how to design traits and what is possible with them.

2 Likes

Yes, I do totally take your broader point – This is very hard to do and there are many real problems with it. My above comment was mostly trying to give people context for a very concrete limitation of so called ‘Holy traits’. Many people ‘feel’ these limitations but don’t necessarily have a precise understanding or way of phrasing them.

That’s not actually how I would do this, for the same reason I wouldn’t implement multiple dispatch as a series of chained double-dispatches in a single dispatch language. My plan instead has been to wait for the compiler pass infrastructure to solidify a little more (and for some free time to manifest) and then try to implement it as an overlay pass. And you may recall that I in fact did briefly roll up my sleeves at one point and implemented a form of Betray for Traitor.jl.

I think trying to just shoehorn it into existing packages won’t really show much benefit though, since those packages were written with the limitations of the current situation in mind.

5 Likes

Thank you, this is exactly the boat I was in until you elucidated it.

The idea presented here does feel a bit like playing with fire, so it’s definitely worth asking whether the benefits justify the danger.

Note, that a proper cost/benefit analysis cannot be made unless we have a good idea how well we can control the flames.

1 Like

I think correctly specifying via the types of functions in packages what capabilities (like generic indexing or 1-based indexing) are supported is that killer application. See also the discussion in the thread about 0-based indexing being a poison pill.

I find traits in Scala pretty useful.

I actually don’t think traits are the right solution for this one. I think this is better addressed by instead making it easier to use 1-based indices to access containers without 1-based indices, so that 1-based indexing can be considered “generic” again.

One idea is to make a 1-based index type, as illustrated by the OrdinalIndexing.jl package: e.g., v[1st], v[2nd], v[(1:5)th]. Such indices offer a 1-based indexing assurance.

Another idea is to make a 1-based indexing view, as @DNF ponders in this post: e.g. OneBasedView(v)[1:5]. This seems inconvenient, but to illustrate how it’s not so bad: you could write a function like this:

hermitian!(A) = let A = OneBasedView(A), (m, n) = size(A)
    m == n || error("Matrix must be square")
    @inbounds for i = 1:m, j = i:n
        A[i, j] = A[i, j] + A[j, i]'
        A[j, i] = A[i, j]'
    end
end

Notice that OneBasedView is called only once at the top, and the rest of the function accesses A through this view.

These possibilities are also pondered in this thread.

1 Like

@uniment, if your post is indirectly asking the question, “mind if I try adding better traits to Julia?” then by all means go for it! No one wants to hold back a trait-enthusiast, I was just explaining the challenges.

Just keep in mind that there are good reasons for being conservative when it comes to merging big PRs that might add features we’d later regret—with Julia in stable release mode, there is no going back once a release has been made. Demonstrating tangible benefit from your work as early as possible will help convince a lot of people that big PRs should be merged.

2 Likes

I wouldn’t call myself that. I’m just an engineer, and when people complain, my animal instinct is to seek a technical solution :sweat_smile: I’ll let others debate whether traits are important enough to justify language support (I’m currently undecided).

My intent here is to add some heat to anneal the problem, to test whether we’re stuck in a local optimum or if we’ve found the best solution. What I’ve seen suggested that we hadn’t. An idea came to mind that appears to address the pain points, so I wanted to test it through dialog rather than putting in tremendous effort implementing it only to discover it was a poor idea from the outset.

I appreciate that this approach is often preferable. However, for this idea, proper implementation as a package would take maybe ten times as much effort and yield an inferior and unpleasant result, so it’s a poor approach and winning the package popularity contest wouldn’t be a good litmus test. I’ll also take this opportunity to point out that approaches that are optimal for packages aren’t always optimal for language-level solutions. Brainstorm/debate/rubber duck debug seems more appropriate for now.

Speaking of which, through this rubber ducking I found something I legitimately dislike about this proposal. Take the idea of dispatching on a container’s element type:

foo(A::AbstractArray{E}) where {E<:ElType} = ...

Naturally we would wish to extend this idea, to be able to dispatch on the traits of the element type. But this proposal offers no such mechanism. :frowning:

Maybe :: could be used?

foo(A::AbstractArray{E::ETr} where {E<:ElType, ETr>:ElTraits} = ...

I can’t say I’m a fan of this syntax though.

Also, by this proposal we’d be able to express vararg traits like so:

bar(x::X::XTr...) = ...

but there’s currently no way to express this using Vararg or NTuple. It could be expressed as:

bar(x::Vararg{X::XTr})

Still, the syntax irks me. It doesn’t feel right.

What I think you’re looking for is the sort-of dual to Union - instead of being a subtype of one of its parameters, you’d want to require T to be a subtype of ALL of its parameters (I don’t recall the proper type theoretic name, so I’ll just call this Join for now - think of it as a non-eager typejoin, just aggregating types without collapsing to Any or some Union). That is, in your Platypus example you’d write it like

abstract type HasDuckBill end
abstract type LaysEggs end

abstract type Mammal end

struct Platypus <: Join{HasDuckBill, LaysEggs, Mammal} end
struct CanadianGoose <: Join{HasDuckBill, LaysEggs, Bird} end

foo(arr::AbstractArray{T}) where {T <: Join{HasDuckBill, LaysEggs}} = "Platypus or Goose"

This can of course lead to lots of ambiguity errors, e.g. if we just add the innocent looking

foo(arr::AbstractArray{T}) where {T <: Mammal} = "Not a bird!"

then

foo(Platypus[])

is ambiguous. This particular approach still has the disadvantage of not being able to easily add new traits to existing objects, since it keeps the type system of julia nominal (see the docs and wikipedia). This is in principle fixable - just don’t require Join to be declared a supertype, i.e. don’t make it a nominal part of the type system - but I haven’t thought about the consequences of that too deeply. It’s so far the best I’ve come up with to introduce traits & multiple abstract subtyping to julia though :person_shrugging: I quite like it, but solving the issues with it I haven’t found yet and actually implementing that is probably worth a master level thesis (who knows, maybe I’ll come back to this approach when I go for a masters…)

4 Likes

Are you thinking of

2 Likes

That looks about right, yes! Good to know that built-up intuition about where our type system has holes leads to existing theory :smiley: Now integrating that theory (and, if possible, in a backwards compatible way) is the hard part… I still like to think Join is a good name for this, since in principle Join{Int, Float16} <: typejoin(Int, Float16) should hold as well, I think.

I usually think of “join” as corresponding to union and “meet” as corresponding to intersection. Julia’s typejoin may be using “join” in some other sense I’m not familiar with.

Yes, that is the usual terminology and I agree that calling it Join is confusing in that sense - it wasn’t meant as a serious suggestion as a name either way, Meet is just as good (and delightfully concise) :slight_smile: The name is yak shaveable.

1 Like

I’ve been chewing on this for a minute and woke up this morning with this exact thought! Glad I’m not crazy :grin:

I think that this captures essence of what people sometimes clumsily complain about Julia’s type system – “I want to inherit from two types.”

You don’t need a separate construct for traits, I think. Just a smart way to pass on behavior from multiple supertypes.

1 Like