Order-agnostic dispatch

Let’s say I have a function like:

function f(x::TypeA, y::TypeB, z::TypeC)
   ...
end

For this particular function f it does not matter what the order of the arguments is, meaning f(x::TypeA, y::TypeB, z::TypeC) == f(::TypeB, y::TypeC, z::TypeA) == f(::TypeC, y::TypeA, z::TypeB) etc.

How can I make f work on every combination of arguments without having to write out every single one by hand? Dispatching on a single abstract type would not work because f could have multiple methods, each operating on different types.

f(args...) = _f(_canonical_order(args...)...)

_f(x::TypeA, y::TypeB, z::TypeC) = ...

if _canonical_order can be implemented recursively or in some other clever way.

Can you give a concrete description of the function you are trying to implement? A lot of the examples where argument-order doesn’t matter fall into one of two categories:

  1. Commutative numerical operations on numerical arguments (like + or hypot). In this case you either promote them to a common type with f(args...) = f(promote(args)...) or (in cases where mixed types can be handled more efficiently, like real + complex) you canonicalize the order.
  2. Cases where you should probably use keyword arguments instead.

This particular example came up when working on Copulas.jl.

I have to compute a correction factor, based on two input marginal distributions. The generic case requires a quadrature and a bisection, which is expensive. But there are closed form solutions for a number of special cases.

Essentially:

# closed-form problems
function _nataf_problem(::Distributions.Normal, Fⱼ::Distributions.LogNormal,
                        ρ::Real, nodes::Integer)
    # r(ρ₀) = ρ₀ s/√(exp(s²) - 1)
    ...
end
function _nataf_problem(::Distributions.Uniform, ::Distributions.Normal,
                        ρ::Real, nodes::Integer)
    # r(ρ₀) = ρ₀ √(3/π).
    ...
end
... list goes on with more special cases

But the order of the marginals doesn’t matter, so you also need the reverse:

# now reserve the order and forward
function _nataf_problem(Fᵢ::Distributions.LogNormal, Fⱼ::Distributions.Normal,
                        ρ::Real, nodes::Integer)
    return _nataf_problem(Fⱼ, Fᵢ, ρ, nodes)
end
function _nataf_problem(Fᵢ::Distributions.Normal, Fⱼ::Distributions.Uniform,
                        ρ::Real, nodes::Integer)
    return _nataf_problem(Fⱼ, Fᵢ, ρ, nodes)
end
... etc

For two arguments, this is of course doable to write out. But for more arguments it quickly explodes. So I was thinking, is there a general way to handle this, even if you had more arguments? I could not find one easily…

(post deleted by author)

Positional arguments are fundamentally distinguished by order, and there’s no stable static sort for types to get around this. Two not-great options off the top of my head:

  1. Runtime sort a vector of distributions by x -> objectid(typeof(x)). That’s an internal detail and can easily change across versions or perhaps even processes, so you wouldn’t want this to be static. Then again, you wouldn’t want this at runtime if you want this to be optimized.
  2. @eval loop for each correction factor formula over permutations of an n-tuple of marginals annotation symbols. Yes, this metaprogramming blows up to factorial(n) methods like the manually forwarding methods, but at least you’re only writing out the template.
julia> begin
       using Combinatorics: permutations
       struct A end
       struct B end
       for (x,y) in permutations((:A, :B))
         @eval f(::$x, ::$y) = "AB"
       end
       f(A(),B()), f(B(),A())
       end
("AB", "AB")

Thanks. Yes, this is fighting positional dispatch which is why it’s so awkward to handle. I was somewhat hoping someone else ran into this problem maybe years ago already and came up with a generic solution that I just missed. It seems broad enough that it would come up more often, but alas?

For the two-argument case I did find this solution using Core.applicable:

function nataf_problem(Fᵢ, Fⱼ, ρ, nodes)
    if applicable(_nataf_closed, Fᵢ, Fⱼ, ρ, nodes)
        _nataf_closed(Fᵢ, Fⱼ, ρ, nodes)
    elseif applicable(_nataf_closed, Fⱼ, Fᵢ, ρ, nodes)
        _nataf_closed(Fⱼ, Fᵢ, ρ, nodes)
    else
        _nataf_generic(Fᵢ, Fⱼ, ρ, nodes)  
    end
end

_nataf_closed(::Normal, Fⱼ::LogNormal, ρ, nodes) = ...
_nataf_closed(::Uniform, ::Normal, ρ, nodes)     = ...

This should compile to true/false so it doesn’t cost anything in runtime.