Vararg (sub)type parameter undocumented dispatch weirdness

a1(::Integer...) = nothing
a2(::I...) where {I <: Integer} = nothing
a3(::Vararg{Integer}) = nothing
a4(::Vararg{<:Integer}) = nothing
a5(::Vararg{I}) where {I <: Integer} = nothing
b3(::NTuple{n, Integer}) where {n} = nothing
b4(::NTuple{n, <:Integer}) where {n} = nothing
b5(::NTuple{n, I}) where {n, I <: Integer} = nothing

# Intended for calling the above with heterogeneous arguments
a(f) = f(zero(UInt8), zero(Int8))
b(f) = f(a(tuple))

a(a1)
a(a2)  # throws MethodError
a(a3)
a(a4)  # unexpectedly succeeds
a(a5)  # throws MethodError
b(b3)
b(b4)  # throws MethodError
b(b5)  # throws MethodError

Why does a(a4) succeed? In particular, how is it different from a(a5) and from b(b4)?

2 Likes

a4 requires the arguments to be Integers. a5 requires all arguments to be of the same type I, which in turn is a subtype of Integer.

So a4 behaves the same as a3 and b3.
But why does it not behave the same as b4, in both a4 and b4 we have <:T used in the place of the type parameter?

I think the answer is here:

julia> methods(b4)
# 1 method for generic function "b4":
[1] b4(::Tuple{Vararg{var"#s6", n}} where var"#s6"<:Integer) where n in Main at REPL[15]:1

The compiler internally assigns a name to the integer part in b4’s signature. So it ends up being the same as a2 where the integer has to be the same concrete type.

1 Like

The difference is subtle: a4 matches argument tuples of the type Tuple{Vararg{T} where T <: Integer}, while a5 narrows to Tuple{Vararg{T}} where T <: Integer. When called on two or more arguments, the latter is a diagonal type that restricts T to range over only concrete types, hence the MethodError.

1 Like

OK, so it seems that I can call methods(my_function) to find out how the dispatch will behave for my function.

But does some consistent set of rules exist that I could learn to be able to predict this myself, because I’d like to avoid calling methods all the time?

EDIT: no, actually methods doesn’t even help me much, because it tells you almost nothing about a4, unlike with b4. And a4 is the surprising one for me.

The easiest way to test dispatch behavior is with subtyping relations. Julia’s dispatch rule is very simple and consistent in this respect: the method selected has the most specific type signature that your argument is a subtype of. For example:

julia> x = zero(UInt8), zero(Int8)
(0x00, 0)

julia> typeof(x) <: Tuple{Vararg{<:Integer}}  # a4's method signature
true

julia> typeof(x) <: Tuple{Vararg{T}} where T<:Integer # a5's method signature
false

The developer documentation is a good reference for Julia’s subtyping rules. For a deeper dive, this paper is a classic.

1 Like

Turns out that the mysterious a4 was actually declared with a deprecated syntax, as shown by starting Julia with --depwarn=error:

$ julia --depwarn=error
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.10.0-DEV.453 (2023-01-28)
 _/ |\__'_|_|_|\__'_|  |  Commit 7d4309c9c31 (0 days old master)
|__/                   |

julia> a4(::Vararg{<:Integer}) = nothing
ERROR: Wrapping `Vararg` directly in UnionAll is deprecated (wrap the tuple instead).
Stacktrace:
 [1] UnionAll(v::TypeVar, t::Any)
   @ Core ./boot.jl:257

I guess this revelation makes my questions from a few months ago moot, although it’s really too bad that Julia has to be started with a special flag to tell you this information.