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).

3 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.

2 Likes

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)
1 Like

Thanks for the answer! Your solution definitely works for the example I gave in the OP.

Unfortunately, in my actual use case, the number of elements in the Tuple-type argument a::Tuple{T, Vararg{T, N}} where {T, N} can vary, and I want to have a universal constraint for all N. In other words, what I actually have is something like this:

f3(a::Tuple{Union{T, Complex{T}}, Vararg{Union{T, Complex{T}}, N}}) where {T<:Real, N} = a

Therefore, I cannot simply write down all the combinations of types to form a concrete union.

Honestly, this issue I encountered made me think that Julia’s support for constructing well-constrained and decidable subtypes from UnionAll is still incomplete.

It seems that as soon as parameterized invariant types are mixed with covariant types, the rules of diagonal types (or “diagonal-type parameters” T more specifically) become ill-defined. This is the only description of when the diagonal type parameters should be restricted to concrete types (a.k.a, a “concrete-type restriction”) I found in the official documentation:

It turns out that being able to dispatch on whether two values have the same type is very useful (this is used by the promotion system for example), so we have multiple reasons to want a different interpretation of Tuple{T,T} where T . To make this work we add the following rule to subtyping: if a variable occurs more than once in covariant position, it is restricted to ranging over only concrete types. (“Covariant position” means that only Tuple and Union types occur between an occurrence of a variable and the UnionAll type that introduces it.) Such variables are called “diagonal variables” or “concrete variables”.

However, this so-called “covariant position” does not seem to be a sufficient condition to enforce the concrete-type restriction since f3 in your example does not allow f3((1, 1.0im)):

even though Complex{T} is an invariant type.

One the other hand, when Complex{T} is mixed with Union, , e.g., for f4, somehow T no longer has the concrete-type restriction…

To verify this inconsistency isn’t a special quirk about Complex, but is really about the mixing of covariant types and Union, we can also test the following cases:

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

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

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

julia> f4_2((1, [1.0]))
(1, [1.0])

Overall, I think it’s worth to at least improve the documentation of when the concrete-type restriction takes effect for the type parameters in the diagonal types, if the existing inconsistent behavior is not deemed “problematic”.

Oh, that’s just incomplete, as visible from (1, 1.0, Real[]) isa Tuple{T,T,Vector{T}} where T. It has to be:

if a variable occurs more than once in covariant position and never in invariant position, it is restricted to ranging over only concrete types

In many other documentation places, this is indeed stated clearly.

The thing that makes your example very tricky is how Union interacts with that rule. For example,

$ julia +1.0 -e "println((1, 1.0) isa Union{Vector{T}, Tuple{T,T}} where T)"
false
$ julia +1.4 -e "println((1, 1.0) isa Union{Vector{T}, Tuple{T,T}} where T)"
false
$ julia +1.5.0 -e "println((1, 1.0) isa Union{Vector{T}, Tuple{T,T}} where T)"
true
$ julia +1.12 -e "println((1, 1.0) isa Union{Vector{T}, Tuple{T,T}} where T)"
true