Some disclaimer: I am still learning Julia and I think it’s natural for me to compare it with a language I am fluent in – Rust. But I am not here arguing Rust is better than Julia or vice versa. I am totally happy and open to learning different paradigm.
I have been recently writing julia code and got into an awkward situation. I have a function f that has a fallback logic
function f(foo)
# fallback logic
end
And I want to handle a special case for AbstractVector{<:Integer}. So I have
function f(foo::AbstractVector{<:Integer})
# special case logic
end
However, the catch is Bool <: Integer, and so AbstractVector{Bool} <: AbstractVector{<:Integer}.
And because of the type invariance, I also cannot write AbstractVector{Integer} as AbstractVector{Int64} is not a subtype of it.
However, what I really want to do is to handle a special case for “vector of integer”. So to stop Bool from going into these special logic I have to duplicate the fallback logic for AbstractVector{Bool}. This is quite awkward to me.
So my questions are:
What’s the best practice in this situation. Additionally, to avoid these situations, it seems like I have to know the type hierarchy very well. How to watch out for all these special cases?
I understand true + 3 ==4 might be handy as are many cases detailed in the issue above. But I feel this case is not strong enough to make Boolalways a subtype of Integer. In Rust, one can handle such situation by implementing an Add<Rhs = bool> trait for i32 for example. And this is essentially orthogonal to the rest of the code, i.e. in other cases, Bool and integer are quite independent from each other. My current understanding makes me feel Julia’s type hierarchy doesn’t have enough granularity that it should have.
The type hierarchy in Julia is a tree. If you need some other division or want to step out of the hierarchy, you can use the Holy trait pattern:
struct Default end
struct Special end
f(foo) = _f(foo, traitof(foo))
traitof(x) = Default()
traitof(x::AbstractVector{T}) where {T<:Integer} = if T == Bool; Default() else Special() end
_f(foo, ::Default) = :fallback_logic
_f(foo, ::Special) = :special_logic
This actually quite similar to how types can be assigned to traits in Rust, but more flexible in that it readily works with multiple dispatch.
That’s not easy, since it’s possible to add new types to the hierarchy. Someone might e.g. define a ModularInteger{n} <: Integer and try to use your function. It may or may not work. If your function is only intended for “system” integer types, you can do somthing like this:
foo(x) = #fallback
const MyInteger = Union{Signed, Unsigned}
foo(x::AbstractVector{<:MyInteger}) = # the real code
The typical ways to handle this has been discussed already, and the only thing I can think to contribute is that if you don’t feel like moving logic to an internal helper method, you can use invoke for this purpose. It’s vulnerable to the fallback method being replaced by more specific methods though, so maybe that’s why it’s not used as much.