julia> 1 isa Int
true
julia> (1,) isa Tuple{Int}
true
julia> Int isa Type{Int}
true
julia> (Int,) isa Tuple{Type{Int}} # ???
false
And how does dispatch work if this doesn’t work for method signatures as tuple types?
julia> 1 isa Int
true
julia> (1,) isa Tuple{Int}
true
julia> Int isa Type{Int}
true
julia> (Int,) isa Tuple{Type{Int}} # ???
false
And how does dispatch work if this doesn’t work for method signatures as tuple types?
typeof((Int,)) is not Tuple{Type{Int}}, but Tuple{DataType}, and accordingly this does work:
julia> (Int,) isa Tuple{DataType}
true
The point as I understand it is related to:
julia> isconcretetype(DataType)
true
julia> isconcretetype(Type{Int})
false
Counterintuitive? I’d say yes, but correctly documented:
For each type
T,Type{T}is an abstract parametric type whose only instance is the objectT.
(emphasis added).
This means that once you wrap a DataType (Int, Float64…) inside a Tuple, it is no longer possible to distinguish it from any other DataType by looking into the type of that tuple, just as you cannot distinguish (1,) from (2,) only by the tuple’s type - you have to access directly the members of the tuple to do it.
The reason I would think it could work otherwise is that:
julia> Type{Int} <: DataType <: Type
true
julia> [(Int,) isa Tuple{T} for T in (Type{Int}, DataType, Type)]
3-element Vector{Bool}:
0
1
1
So while it’s true that the concrete tuple type only specifies DataType, I would expect tuple type covariance to apply to an abstract subtype as well as an abstract supertype.
To elaborate on how it seems to contradict method signatures:
julia> foo(::Type{Int}) = 1;
julia> methods(foo)[1].sig
Tuple{typeof(foo), Type{Int64}}
julia> (foo, Int) isa methods(foo)[1].sig
false
julia> bar(::Int) = 1;
julia> (bar, 1) isa methods(bar)[1].sig
true
This is a long-standing gripe of mine, and it can cause real performance problems.
It’s also the source of ocassional confusion for people using BenchmarkTools.jl:
julia> @btime convert(Int, 1.0)
1.002 ns (0 allocations: 0 bytes)
1
julia> @btime convert($Int, 1.0)
51.587 ns (0 allocations: 0 bytes)
1
The second one is slow because interally $Int got put into a Tuple which made the compiler not able to know what it was, so a dynamic dispatch was inserted on DataType.
The way I typically work around this is by wrapping types in Tuples with some thin wrapper:
struct TWrapper{T} end
maybe_wrap(x) = x
maybe_wrap(::Type{T}) where {T} = TWrapper{T}()
maybe_unwrap(x) = x
maybe_unwrap(::TWrapper{T}) where {T} = T
wrapped_tuple(args...) = maybe_wrap.(args)
and then e.g.
julia> typeof(wrapped_tuple(Int, 1, 2, Float64))
Tuple{TWrapper{Int64}, Int64, Int64, TWrapper{Float64}}
so now the type information isn’t being erased down into a pesky DataType.
It can also sometimes be handled with @generated functions, as in Puzzling inference with `Base.Fix` · Issue #59928 · JuliaLang/julia · GitHub
I don’t think this has to correlate with the break in tuple covariance, but this also crops up often with the implementation of keyword arguments. The compiler can overcome it in narrow circumstances, but as of 1.12, keyzero(;t::Type{T}) where T = zero(T) is generally still type-unstable because a call lowers to Core.kwcall(::@NamedTuple{t::DataType}, ::typeof(myzero)). That’s despite the internal positional method (something like var"#myzero#6"(t::Type{T}, ::typeof(myzero)) where T) specializing over the type as stated by the Performance Tips section on the specialization heuristic. I usually just use positional arguments instead, but a similar fix by an instance with a type parameter would be keyzero(;t::Val{T}) where T.
The implementation details are over my knowledge, but about this particular comment I wonder: if DataType is a concrete type, why is this considered type-unstable?
DataType is only enough for type stability if we’re doing generic type computation, not instantiation. In this case, the downstream zero(t::DataType) can only be inferred as Any. The static parameter in Type{T} is the typical way for dispatch to preserve an input type for the compiler, but that information is lost in a container like Tuple and thus the following code that accesses its elements. If the following code is part of the same method that made the Tuple or is a callee method that gets inlined, then the compiler might still recognize the input type at a specific element or in splatting. It’s still not great to be at the mercy of inlining heuristics or internal implementations of benchmarking, keyword calls, etc., and for something we can semantically guarantee with instantiable parametric wrappers at the cost of extra writing.