A repeated gripe I see from the local gray-hairs about Julia’s type system pertains to the lack of language support for multiple inheritance through traits—to manage those pesky behaviors that fall outside the type hierarchy.
Five years ago, the attitude seemed to be pretty strongly that language-level traits were right around the bend:
To quote @tim.holy in the Holy Traits OP:
It’s a little ugly
There was a flurry of competing ideas, but since then it feels like it tapered off and Holy Traits were written into the language manual. What happened? Was it found that language-level traits support would cause too many method ambiguities? Was no agreeable approach found? Was it decided that macros in packages such as from SimpleTraits.jl or WhereTraits.jl are the best approach? Did fear over too much change set in from the Python 3 drama? Was it simply placed on the back burner as more pressing matters arose?
I have an idea.
A Simple(r) Language-Level Traits Idea
An idea that’s been rattling around my otherwise vacant mind has been that dispatch could occur simultaneously on type hierarchy and on traits, using patterns and concepts that are already familiar.
First, imagine if a traits
function was defined:
traits(::Type{T}) where {T<:Any} = Union{}
such that calling traits(T)
would return the traits that T
implements (by default none).
Utilities like this might be helpful for associating and disassociating traits with a type:
add_traits(T::Type, Tr) = let Trs = Union{traits(T), Tr}
eval(:(traits(::Type{var"#T"}) where {var"#T"<:$T} = $Trs))
end
subtract_traits(T::Type, Tr) = let Trs = Core.Compiler.typesubtract(traits(T), Tr, 0)
eval(:(traits(::Type{var"#T"}) where {var"#T"<:$T} = $Trs))
end
Let’s consider how dispatch could be reworked for these traits.
Currently, dispatch resembles something like this:
# function call
f(a, b, c, d)
# method signature
f(::Any, ::B, ::Any, ::D)
# this method is chosen if:
typeof(a) <: Any &&
typeof(b) <: B &&
typeof(c) <: Any &&
typeof(d) <: D
# (and, of course, if this is the most specific method
# that satisfies these constraints.)
Now, imagine if type-annotation were extended into type-trait-annotation with a syntax similar to Traitor.jl: x::X::XTr
where x
is the identifier, X
is the type, and XTr
the trait(s), allowing for both type and traits to be specified:
x
without decoration has no type or trait annotation;x::X
has just type-annotation;x::::XTr
has just trait-annotation; andx::X::XTr
annotates both type and traits.
Then, dispatch could work like this:
# function call
f(a, b, c, d)
# method signature
f(::Any, ::B, ::::CTr, ::D::DTr)
# this method is chosen if:
typeof(a) <: Any && traits(typeof(a)) >: Union{} &&
typeof(b) <: B && traits(typeof(b)) >: Union{} &&
typeof(c) <: Any && traits(typeof(c)) >: CTr &&
typeof(d) <: D && traits(typeof(d)) >: DTr
# (and if this is the most specific method
# that satisfies these constraints.)
Unlike Traitor.jl, if DTr
is a set of traits Union{DTr1, DTr2}
, then d
’s traits must include both: increasing the size of the set DTr
tightens the specification. So while the type annotation D
gets more specific as you progress down a type hierarchy, the traits annotation DTr
gets more specific as you go up.
For example, to express the idea “the platypus is a mammal with a duck bill”:
abstract type Mammal end
struct Platypus <: Mammal legs; tail end
struct HasDuckBill end
struct LaysEggs end
add_traits(Platypus, Union{HasDuckBill, LaysEggs})
show(io::IO, ::::HasDuckBill) = print(io, "Probably a duck type...")
show(io::IO, ::Mammal::HasDuckBill) = print(io, "Maybe a platypus?")
show(io::IO, ::Mammal::Union{HasDuckBill, LaysEggs}) = print(io, "Probably a platypus.")
show(io::IO, ::Platypus) = print(io, "Definitely a platypus!")
An example a little closer to something you might see:
foo(A::AbstractArray, args...) = ... # default for arrays
foo(A::AbstractArray::IndexCartesian, args...) = ... # trait specialization
foo(A::AbstractArray::IsStrided, args...) = ... # trait specialization
foo(A::AbstractArray::Union{IndexCartesian, IsStrided}, args...) = ... # deeper trait specialization
foo(v::AbstractMatrix::Union{IndexCartesian, IsStrided}, args...) = ... # deeper type specialization
Of course, traits
should be overloadable with custom methods, which hopefully would satisfy @klacru. Wrapper types might find it useful to inherit traits from the type they wrap:
traits(::Type{T}) where {T<:Symmetric{A,B}} where {A,B} = traits(B)
Matching on multiple objects that share the same traits:
foo(x::::Tr, y::::Tr) where {Tr<:Union{}} = ...
For completeness, to illustrate the contrast between type and trait annotation specificity:
x::Any::Union{} # most-general (matches any object)
x::Union{}::Any # most-specific (matches no object)
I like that this seems to be backwards-compatible, retaining the type hierarchy which works smoothly most of the time, and only adding traits when they’re really needed—when some behaviors for a type are orthogonal to its type hierarchy. Most code would remain as-is.
Unfortunately, I think trying to mock this up with a macro would be pretty messy and a low-fidelity representation, so I might only try if there’s a very compelling reason.
Would it work? Does it need a more/less sophisticated trait inheritance mechanism? Does it open room for dispatch ambiguity that couldn’t be solved by the patterns in the manual? Would it be necessary to impose constraints on traits? Would it necessitate new design patterns? Does it make dispatch too complex and inefficient? Are there any glaring weaknesses? Are Holy Traits good enough? Should we be using macros? Please share your thoughts!