"Traits" which may be defined on types or values?

I’m trying to learn about [Holy] traits for the first time. It seems that in the classic implementation, an object’s traitfulness depends only on that object’s type, not value. For example, maybe I want to classify certain types as cool or not:

abstract type SweetTrait end
struct IsSweet <: SweetTrait end
struct NotSweet <: SweetTrait end

SweetTrait(::Type) = NotSweet() # default is not sweet
SweetTrait(::Type{Rational}) = IsSweet() # rationals are cool
SweetTrait(::Type{String}) = IsSweet()

announce(x::T) where {T} = announce(SweetTrait(T), x)
announce(::IsSweet, x) = println("$x is cool")
announce(::NotSweet, x) = println("$x is boring")

But what if I want some objects’ traitfulness to depend on the actual values, not just the object type? The following seems to work, but it’s a pattern I don’t think I’ve seen in the traits examples. Is it non-performant, an antipattern? Is there a better way to accomplish this?

SweetTrait(::Any) = NotSweet() # default is not sweet
SweetTrait(::Rational) = IsSweet() # rationals are cool
SweetTrait(x::Int) = ifelse(x == 47, IsSweet(), NotSweet()) # 47 is the only cool integer

announce(x)= _announce(SweetTrait(x), x)
_announce(::IsSweet, x) = println("$x is cool")
_announce(::NotSweet, x) = println("$x is boring")

It is not type stable. You could use enums instead, and dispatch using branches.

4 Likes

You could also use Val for this, i.e. something along the lines of

SweetTrait(x::Int) = SweetTrait(Val(x))
SweetTrait(::Val{N}) = NotSweet()
SweetTrait(::Val{47}) = IsSweet()

But also not type-stable (with the exception of constant-propagation). This is a case where a nuanced choice is required. Val tricks or traits based on values do have a place in allowing you to create APIs that may be readily extended by others, so there are occasions where I might choose to do things this way. But I’d do it with awareness that performance was going to take an orders-of-magnitude hit, so I’d make this kind of choice only when performance of this part of the code is irrelevant. By co-opting dispatch for this, you’re signing up for runtime dispatch, which is vastly more expensive than the enum & branches approach suggested by @Elrod.

In contrast, traits/Val tricks based on types are “free”, because the types are known to inference so it knows what the trait-type will be and can decide the dispatch at compile time (as long as inference “works” on your code, which it does on most well-designed code).

5 Likes

That sounds interesting. Could you give a small example of how to do that exactly?

Thanks for the explanation! I am not yet always aware of performance issues, but sure, eager to learn. Then, too, be interested in an enum & branch approach.

1 Like

It’s simpler than you may be imagining. Here’s one that doesn’t involve Enum (that’s kind of an independent issue):

function foo(x)
    x > 47 && return foo_big(x)
    x > 13 && return foo_medium(x)
    return foo_small(x)
end

This involves at most 2 comparison operations, and the compiler can compile-time-dispatch each call site (there are 3 methods that might be called, but there are 3 sites from which you make that call, so it all works out). In contrast, one that looks more like runtime dispatch,

function foo_rt(x)
    t = x > 47 ? Big() : (x > 13 ? Medium() : Small())
    foo_rt(t, x)
end

you have just a single call site that might go 3 places. This is not a perfect example, because since there are only 3 choices Julia will use union-splitting, but in a case where there are more possibilities (like Val(x)) then Julia can’t use union-splitting.

11 Likes

Thanks for the explanation and perfect illustration.

Thanks for the helpful response! Can you give an example of when Val tricks or values-traits do make sense?

I’m imagining something like using values-traits as syntactic sugar to choose an algorithm based on values? E.g. maybe you have heuristic A which works on all vectors and B which works on vectors with only nonnegative values, but both A and B have runtime >>> runtime dispatch cost, so it’s okay to use value-traits to choose the algorithm at runtime? Though now that I write that, this case seems like a natural fit for manual if dispatch, so maybe you meant value-traits and kin thereof are appropriate for a different use case?

Maybe a UI that dispatches on keypresses?

function handle_keypress(guidata, ::Val{'c'})
    # do something when the user presses 'c'
end

function handle_keypress(guidata, ::Val{Key}) where Key
    error("No handler for ", Key, " has been defined")
end

This would make it easy for others to add additional functionality just by defining new handle_keypress methods in their local codebase.

This is definitely not a situation demanding high performance, and a trick like this should never be used when performance is necessary.

3 Likes

The other place where val based dispatch makes sense is places where you expect the value to be a constant in the code. Then you generally get constant folding to eliminate the issue.

1 Like