Coupling finite`Union` with `T` in `NTuple{2, T} where {T}`

We know that for UnionAll of Tuple that uses the same type parameter (e.g., T) for all the element types (in default) requires the element types to be the same concrete type:

julia> f1(a::Tuple{T, T}) where {T<:Real} = a
f1 (generic function with 1 method)

julia> f1((1, 1))
(1, 1)

julia> f1((1, 1.0))
ERROR: MethodError: no method matching f1(::Tuple{Int64, Float64})
The function `f1` exists, but no method is defined for this combination of argument types.

I was hoping that if I replace T with a finite Union of types parameterized by T, the requirement of T to be the same concrete type still holds:

julia> f2(a::Tuple{Union{T, Complex{T}}, Union{T, Complex{T}}}) where {T<:Real} = a
f2 (generic function with 1 method)

julia> f2((1, 1.0)) # Expect this to prompt `MethodError` assuming `T` iterate through all possible concrete types

But that’s not the case:

julia> f2((1, 1.0)) # This actually is legal as `T==Real` somehow is allowed
(1, 1.0)

Is this consistent with the design of UnionAll?

If so, how can I parameterize the type signature of f2 to allow each element of a to be either T or Complex{T}, but must have the same concrete T? In other words, any (T1, T2) in Tuple{Union{T1, Complex{T1}}, Union{T2, Complex{T2}}} such that T1!==T2 || !isconcretetype(T1) should be disallowed.

Thanks!

3 Likes

Consider

julia> typ = Union{ Tuple{T,T} where T<:Real, Tuple{T,Complex{T}} where T<:Real, Tuple{Complex{T}, Complex{T}} where T<:Real, Tuple{Complex{T}, T} where T<:Real }

Note that this means that you cannot pattern-match / extract T.

But this is a nice example!

And it imo demonstrates why the diagonal rule kinda sucks. (it’s awesome syntax, but it would be clearer if we kept it as syntactic sugar for something like Tuple{T,T} where T<: CONCRETE).

2 Likes

Yes. It’s Tuple and Union which are specially treated in the sense that if a type variable occurs more than once and only inside Tuple and Union (in “covariant position”), it is required to be concrete.

Perhaps this:

f2(a::Tuple{Union{R,Complex{C}}, Union{R,Complex{C}}}) where {R, R<:C<:R} = a

Edit: no:

julia> f2((1,Complex{Real}(1.0, 1.0)))
(1, 1.0 + 1.0im)
1 Like

Things get strange when type parameters are allowed to not be concrete types. It makes sense to accommodate abstract type parameters sometimes:

julia> foo(a::T, ::Vector{T}) where T = (a, T)
foo (generic function with 1 method)

julia> foo(1.0, Real[0])
(1.0, Real)

but if you modify f2 to return (a, T), you don’t actually get T==Real because it’s not the only or narrowest supertype:

julia> f2((1, 1.0))
ERROR: UndefVarError: `T` not defined in static parameter matching
Suggestion: run Test.detect_unbound_args to detect method arguments that do not fully constrain a type parameter.

which I’m not even certain is intended.
A weirder example:

julia> f3(a::Tuple{T, Complex{T}}) where T = (a, @isdefined(T) ? T : "undefined")
f3 (generic function with 1 method)

julia> f3((1, 1.0im))
ERROR: MethodError: no method matching f3(::Tuple{Int64, ComplexF64})
...
julia> f4(a::Tuple{T, Union{T, Complex{T}}}) where T = (a, @isdefined(T) ? T : "undefined")
f4 (generic function with 1 method)

julia> f4((1, 1.0))
((1, 1.0), "undefined")

julia> f4((1, 1.0im))
((1, 0.0 + 1.0im), "undefined")

julia> f5(a::Tuple{T, Union{T, Complex{T}}}, ::Vector{T}) where T = (a, @isdefined(T) ? T : "undefined")
f5 (generic function with 1 method)

julia> f5((1, 1.0), Real[])
((1, 1.0), Real)

julia> f5((1, 1.0im), Real[])
ERROR: MethodError: no method matching f5(::Tuple{Int64, ComplexF64}, ::Vector{Real})
...

I don’t know why the concrete diagonal rule applies to f3 when Complex{T} is in all cases, or why it’s not always possible to force a known T with another argument’s type parameter.

The previously linked source about the diagonal rule does imply that a design alternative can demand a concrete T in foo(a::T, ::Vector{S}) where {T, T<:S}. I’m inclined to prefer a concrete-except-abstract-parameters rule, but I don’t know if that falls apart somewhere.

1 Like

This is a bit counter intuitive

If so, how can I parameterize the type signature of f2 to allow each element of a to be either T or Complex{T}, but must have the same concrete T? In other words, any (T1, T2) in Tuple{Union{T1, Complex{T1}}, Union{T2, Complex{T2}}} such that T1!==T2 || !isconcretetype(T1) should be disallowed.

This can be implemented using some traits mechanism:

julia> struct Foo{d} end

julia> Foo{true}(a) = a

julia> f4(a::Tuple{Union{T1, Complex{T1}}, Union{T2, Complex{T2}}}) where {T1 <: Real, T2<: Real}  = Foo{T1==T2 && isconcretetype(T1)}(a)
f4 (generic function with 1 method)

julia> f4((1,1.0))
ERROR: MethodError: no method matching Foo{false}(::Tuple{Int64, Float64})
The type `Foo{false}` exists, but no method is defined for this combination of argument types when trying to construct it.

Closest candidates are:
  (::Type{Foo{d}} where d)()
   @ Main REPL[1]:1

Stacktrace:
 [1] f4(a::Tuple{Int64, Float64})
   @ Main ./REPL[2]:1
 [2] top-level scope
   @ REPL[4]:1

julia> f4((1,1))
(1, 1)

julia> f4((1.,1im))
ERROR: MethodError: no method matching Foo{false}(::Tuple{Float64, Complex{Int64}})
The type `Foo{false}` exists, but no method is defined for this combination of argument types when trying to construct it.

Closest candidates are:
  (::Type{Foo{d}} where d)()
   @ Main REPL[1]:1

Stacktrace:
 [1] f4(a::Tuple{Float64, Complex{Int64}})
   @ Main ./REPL[2]:1
 [2] top-level scope
   @ REPL[6]:1

julia> f4((1.,1.0im))
(1.0, 0.0 + 1.0im)