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")
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).
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.
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.
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?
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.
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.