Define multiple methods or one method with union types?

What is the preferred way to implement methods that allow multiple types?

Option A:

function method(a::Union{Tuple{Integer,Integer}, Integer}, b::Union{Tuple{Integer,Integer}, Integer})
    a = a isa Integer ? (a,a) : a
    b = b isa Integer ? (b,b) : b
    # ...
end

or option B:

method(a::Integer, args...) = method((a,a), args...)
method(a::Tuple{Integer, Integer}, b::Integer) = method(a, (b,b))
function method(a::Tuple{Integer, Integer}, b::Tuple{Integer, Integer})
    #...
end

Is the conversion compiled away in option A or does it introduce a performance hit?

NOTE: This is only an example and I know that in this case, the difference in running time would in fact be negligible. I use it because it is illustratory. Assume that the two conversions in option A are expensive.

In this case option B is definitely better.
It is much more Julian in spirit because it relies on multiple dispatch, which is basically the same as testing a isa ... but happens during compilation.
And as a side bonus, it allows you to give the methods different docstrings.
I’m gonna take a look at performance cause I’m not 100% sure, but I think there is no case in which option A is faster.

EDIT: I did take a look at performance, and at first glance both versions generate the same LLVM code. So the compiler is smart enough to not be disturbed by the type checking. However, in terms of style and clarity option B remains the clear winner.

9 Likes

You might want to consider this pattern.

julia> module OptionC
           @inline _to_integer_integer(x::Integer) = (x,x)
           @inline _to_integer_integer(x::Tuple{Integer,Integer}) = x
           method(a,b) = method(
               _to_integer_integer(a),
               _to_integer_integer(b)
           )
           function method(
               a::Tuple{Integer,Integer},
               b::Tuple{Integer,Integer}
           )
               # ...
               return a,b
           end
       end

You could then extend the functionality by overloading _to_integer_integer:

julia> OptionC._to_integer_integer(x::AbstractArray{<:Integer,N} where N) =
           length(x) in (1,2) ? (first(x),last(x)) :
               throw(ArgumentError("Arrays must be of length 1 or 2"))

julia> OptionC.method([1],[2])
((1, 1), (2, 2))

julia> OptionC.method([1],[2,3])
((1, 1), (2, 3))

julia> OptionC.method(1:2,3:4)
((1, 2), (3, 4))

julia> OptionC._to_integer_integer(x::Pair{<:Integer, <:Integer}) =
           (first(x),last(x))

julia> OptionC.method(1 => 2, 3 => 4)
((1, 2), (3, 4))
3 Likes

Thank you!
As a follow-up, since method B may require to define a lot of methods, is there a way of avoiding the use of both optional arguments and normal arguments swallowing and splatting in the definitions of the methods. An example:

method(a, b::Integer, args...) = method(a, (b,b), args...)

must be written as

method(a, b::Integer, args...; varargs...) = method(a, (b,b), args...; varargs...)

if it has any optional arguments to it. Is there any idiomatic way or clever syntax of avoiding doing this? I would like to avoid defining a macro, since it would make the code non-transparent (in my opinion).

I think this is a typical use case for automated code generation with @eval. This is different from a macro because it’s something you do only once as a package developer (instead of leaving it out there for package users), and also because it looks much simpler (you don’t need to manipulate an abstract syntax tree).

This trick is used a lot even in Base Julia: i did a quick search, and i think examples like this one with Missing are pretty typical.

Side note: @oxinabox wrote a blog post about this exact topic yesterday, I haven’t yet had time to check it out but you may find it interesting. Resident Eval, Top level metaprogramming patterns in JuliaLang

2 Likes