Dispatch over element type

I find the following code too restrictive:

function f(collection::Vector{Int64})
    for i in collection
        println("Integer: $i")
    end
end

function f(collection::Vector{String})
    for s in collection
        println("String: $s")
    end
end

f([4, 5, 6]) # Success.
f(["a", "b", "c"]) # Success.
f((4, 5, 6)) # Failure: collection type is not Vector.
f([Substring("abc", i, i) for i in range(1, 3)]) # Failure: element type is not String.

To make the methods above more flexible to type dispatching, I wish I could write the following:

function f(collection::C) where eltype(C) <: Integer
    for i in collection
        println("Integer: $i")
    end
end

function f(collection::C) where eltype(C) <: AbstractString
    for s in collection
        println("String: $s")
    end
end

Unfortunately, the above is not valid Julia code (yet?). Is is possible to achieve this sort of “abstract covariant dispatching” I’m after? or “sophisticated static trait bounds”? If not, what’s a usual workaround? I would personally go for:

function f(collection)
    if eltype(collection) <: Integer
        f_for_integers(collection)
    elseif eltype(collection) <: AbstractString
        f_for_strings(collection)
    else
        throw(ArgumentError("Invalid type..."))
    end
end

function f_for_integers(collection)
    for i in collection
        println("Integer: $i")
    end
end

function f_for_strings(collection)
    for s in collection
        println("String: $s")
    end
end

… but this feels like too much explicit type checking for a statically typed language, is it?

One answer is

function f(collection)
    for x in collection
        do_scalar(x)
    end
end
do_scalar(x::Int) ...
3 Likes

You can simply write

function f(collection::AbstractVector{T}) where T<: AbstractString
    for i in collection
        println("String: $i")
    end
end

or

function f(collection::AbstractVector{<:AbstractString})
    for i in collection
        println("String: $i")
    end
end

in short.

ADD:
This only fixes the case for abstract type (the last one above).
Be careful of the difference between {<:AbstractString} and {AbstractString}.

f(a::Vector{<:AbstractString}) = true
h(a::Vector{AbstractString}) = true
f([""]) # true
h([""]) # no method found
1 Like

Moreover, the Julia package WhereTraits might meet your requirement.

julia> @traits function f(collection) where eltype(collection) <: Integer
           for i in collection
               println("Integer: $i")
           end
       end

julia> f([1,2,3])
Integer: 1
Integer: 2
Integer: 3

Note: There will have five methods for f by this way.

julia> methods(f)
# 5 methods for generic function "f":
...
2 Likes

He want dispatch Tuple and Vectors…

If it is really matter to include tuples - you should take into account that !(Tuple <: AbstractVector), but isa((1,2,3), Tuple{Vararg{Int}}), so you should make method for tuple of strings:

function f(collection::Tuple{Vararg{<:AbstractString}})
    for s in collection
        println("String: $s")
    end
end
2 Likes

Hi and thank you for your feedback :slight_smile:

Although this does work with the examples I have chosen, you can only generalize this approach to functions where do_scalar can be written (for example, functions with independent processing of every item). In other terms, this solution depends on the actual code within f in a way that sort of bypasses the actual dispatching problem ^ ^"

Yupe. But as you write, this does not dispatch over tuples or generators, only over collections in subtypes(AbstractVectors).

Not exactly, since I would also expect f(Set(range(5)) to work. This alternate collection type is neither a Vector nor a Tuple, but still, eltype(C) <: Integer. Is there no abstract julia type for collections and/or iterables?

This seems exactly powerful enough for my purpose :smiley: Thank you! Can I first make sure that there is no built-in Julia idiom for that before I mark your post as the “solution”?

2 Likes

No. As I know.

I suppose that using dispatch this way is not good idea by design. If you want to work only with collections you can check it applicable(length, [1,2,3])

If you want to dispatch by eltype you can try something like this:

function f(collection, ::Type{<:AbstractString})
    for i in collection
        println(i)
    end
end
function f2(collection)
    if applicable(length, collection) 
        f(collection, eltype(collection))
    end
end
a = ["s","ss", "sss"] 
f2(a)
1 Like

If you want to support arbitrary iterables, I think you want to use traits for this:

f(collection) = _f(collection, Base.IteratorEltype(collection))
_f(collection, ::Base.EltypeUnknown) = ...fallback for unknown eltype...
_f(collection, ::Base.HasEltype) = _f(collection, eltype(collection))
_f(collection, ::Type{T}) where {T<:AbstractString} = ...string code....
_f(collection, ::Type{T}) where {T<:Integer} = ...integer code....
13 Likes

I’d say your original solution is pretty idiomatic. The branching in f is basically cost free, or rather eliminated entirely, since eltype (should) constant propagate. I’m pretty sure @traits generates something very similar, but have a look with @macroexpand.

2 Likes

Hi all, and thank you again :slight_smile:

Can you elaborate on why you think it’s not? For instance, I think I would agree with you if f was an internal function used in some performance-critic part of the code. But (and maybe it’s a useful piece of context indeed:) this is a public API. If I had to describe the design in a nutshell: I don’t want to state unnecessary constraints on my function signature.
All I’m needing here is that collection yields items in an iterable fashion, i.e. for i in collection should not error. As a consequence, I would be unsatisfied to force my callers into using a Vector rather than anything they have at hand like a Tuple, a Set or a lazy iterator.
This being said, and since performance is not a concern, I would agree that converting the received argument to some standard collection like Vector could be useful indeed:

f(collection) = f(Vector(collection))
f(collection::Vector{<:Integer}) = # implement for integers
f(collection::Vector{<:AbstractString}) = # implement for strings
f(::Vector) = throw(ArgumentError("Invalid element type.."))

But I find this somewhat unsatisfying because Vector is only introduced/allocated here for dispatching purpose. Dispatching should not influence implementation this way, right?

This is also what I think. if eltype(collection) <: Integer is a good way to go if I’m not willing to include the third-party @trait macro here.

Oh, I didn’t know about those :smiley: This is very close to the perfect solution because it’s semantically accurate and only uses native Julia concepts. Although it’s a little bit complicated for a small function with only a few expected possible element types, I would definitely go for it if the list of expected possible types needs to be large, or open. Maybe I need to read that part of the docs now?

1 Like

It’s pretty short actually, if you ignore unknown eltypes:

f(collection) = _f(collection, eltype(collection))
_f(collection, ::Type{<:AbstractString}) = ...string code....
_f(collection, ::Type{<:Integer}) = ...integer code....

I think this is the most direct approach.

4 Likes

Yes, this is a good solution and probably rather close to the code that one of the trait libraries would generate.
In any case: What exactly are you trying to do, i.e., why would your function need to behave differently for the element type yet needs access to the whole iterable?
Just asking, because some higher-order function might already do the trick. You already suggested that map(do_scalar, collection) would not be enough. There is also reduce though, which is probably the most general type of processing you can do when the actual collection type is unknown, i.e., you can only rely on iterating over it:

reduce(combine, collection; init=initial)
# roughly equivalent to
begin
    res = initial
    for ele in collection
        res = combine(res, ele)
    end
    res
end

Yep. I think eltype(collection) can decrease performance in critical parts. Also all such types: Tuple{String}, Tuple{String, String} is a different types and you will have different methods for all of them and in some cases it can lead to long compile time.

It shouldn’t — this is evaluated at compile-time if collection is type-inferred.

3 Likes

Also Base.IteratorEltype(1) == Base.HasEltype() and it can be critical in some ways, for example if you want return vector for collection and single element when provide Int.