Holy traits vs "boolean traits"

I’ve been exploring how holy traits are typically implemented in Julia, often seeing patterns like the following:

SomeTrait(x::Type) = B()
SomeTrait(::Type{<:T}) = A()

somefunc(x::T) where {T} = somefunc(SomeTrait(T), x)
somefunc(::A, x) = :a
somefunc(::B, x) = :b

However, I’ve come across a different approach used by libraries such as Tables.jl, where traits are determined by boolean functions, for example:

Tables.istable(t::T) = true

This method also appears to be used by WhereTraits.jl. I’m curious if there’s a specific term for these “boolean” traits and whether there’s a general preference or consensus in the Julia community regarding which trait implementation is considered more effective or appropriate.

2 Likes

In your example, is traitfunc supposed to be somefunc? Also, the T in the definition of LiquidityStyle is not defined. And what is the purpose of LiquidityStyle? It seems that it is not used later on.

Yes, sorry, it’s late. I just wanted to give an example of what I understand as the conventional implementation for Holy traits, so I copied from Holy Traits Pattern (book excerpt), which seems to be the accepted authority for that. Thanks, I corrected the original post!

Just do what’s best in the specific sitation.

1 Like

The main difference between “boolean” and Holy traits are that Holy traits operate in the type system whereas boolean traits use values.

The definitions of each are roughly equivalent but Holy traits are more extensible and probably more performant (due to leveraging Julia’s dispatch).

Using boolean (actually symbols, but works the same) traits:

traitval(::Any) = :none
traitval(::A) = :A
traitval(::B) = :B

somefunc(x::T) where {T} = somefunc(traitval(x), x)
function somefunc(val, x)
  if val == :A
    return :a
  elseif val == :B
    return :b
  else
    # unsure what to do and can't be extended later
  end
end

We could use Val(traitval(...)) and lift this into the type system for dispatch, which is essentially reinventing Holy traits, potentially less efficiently.

Holy traits allow us to easily extend by simply adding something like

SomeTrait(::Type) = B()
SomeTrait(::Type{<:SomeType) = A()

somefunc(x::T) where {T} = somefunc(SomeTrait(T), x)
somefunc(::A, x) = :a
somefunc(::B, x) = :b

...

struct C end
SomeTrait(::CustomType) = C()
somefunc(::CustomType, x) = :c

This means that traits can be added without changing existing code which makes the code more maintainable.

More usefully, since all these operations are operating on types the compiler can generally infer which path will be taken during compilation, avoiding run-time dispatch (ie the if statement in the boolean trait example).

4 Likes

See also

6 Likes

In practice, even with value-based traits the compiler can usually do constant propagation and know which branch to take anyway. So usually there is no run-time penalty.

3 Likes

Yep, that’s what I worried about. In general, I prefer the “boolean traits” as they are closer to what’s usually called traits in other languges (like Rust).

I think it is better to distinguish

  1. traits that return results in the value space (eg values of the same type, such as true::Bool or false::Bool, an Enum, a Symbol, etc),

  2. traits that encode this information in the type space, using designated types such as Base.HasShape{N}, a payload in a Val{T}, etc.

Generally, the first option may be more convenient, but can run into problems unless you branch or condition on the trait insider the caller. When you cross a function boundary, the compiler is not guaranteed to propagate the information with constant folding, so the second option is preferable.

Working in the type space is also better if you want to keep things extensible (users can define new traits), and/or use methods instead of branching.

7 Likes

There are quite a few traits packages these days but BinaryTraits.jl allows you to define something that an object can do (positive) or cannot do (negative). Perhaps you can take a look.

1 Like