Strange behavior of union of types

Hi, I defined some type UNTuple as union of NTuple and NamedTuple{Name,NTuple{N,...}} and got the following unexpected behavior. What I hope for the UNTuple is an N-tuple of numbers of same type. The behavior seems strange and unexpected:

julia> const NNTuple{N,T,NM} = NamedTuple{NM, NTuple{N,T}} where {N,T<:Number,NM}
NamedTuple{NM, Tuple{Vararg{T, N}}} where {N, T<:Number, NM}

julia> const UNTuple{N,T<:Number} = Union{NTuple{N,T},NNTuple{N,T}}
Union{Tuple{Vararg{T, N}}, NamedTuple{NM, Tuple{Vararg{T, N}}} where NM} where {N, T<:Number}

julia> (1,2.0) isa UNTuple{2}
true

julia> (1,2.0) isa NTuple{2}
false

julia> (1,2.0) isa NNTuple{2}
false

julia> (1,2.0) isa Union{NTuple{2},NNTuple{2}}
false

julia> (1,2.0) isa Union{NTuple{N},NNTuple{N}} where N
false

So what’s going on here and how to define UNTuple correctly?

Seems like a bug.

julia> let T=Union{NTuple{N,T}, NamedTuple{K,NTuple{N,T}} where K} where {N,T<:Number}
           Tuple{Int,Int}     <: T,
           Tuple{Int,Float64} <: T, # should be false
           NamedTuple{(:a,:b),Tuple{Int,Int}}     <: T,
           NamedTuple{(:a,:b),Tuple{Int,Float64}} <: T
       end
(true, true, true, false)

This is how it should behave:

julia> let T=Union{NTuple{N,T} where {N,T<:Number}, NamedTuple{K,NTuple{N,T}} where {K,N,T<:Number}}
           Tuple{Int,Int}     <: T,
           Tuple{Int,Float64} <: T,
           NamedTuple{(:a,:b),Tuple{Int,Int}}     <: T,
           NamedTuple{(:a,:b),Tuple{Int,Float64}} <: T
       end
(true, false, true, false)

This seems to arise from the following behavior (that I don’t fully understand):

julia> Tuple{Int,Float64} <: NTuple{2,Number}
true

julia> Tuple{Int,Float64} <: NTuple{2}
false

julia> NTuple{2,Number} <: NTuple{2}
false

julia> NTuple{2,Number} <: NTuple{2,Any}
true
1 Like

WoahNeoGIF

Okay, I think that behavior makes sense.

julia> NTuple{2,Number}, NTuple{2,T} where T<:Number
(Tuple{Number, Number}, Tuple{T, T} where T<:Number)

julia> @show Tuple{Int,Float64} <: NTuple{2,  Number}
       @show Tuple{Int,Float64} <: NTuple{2,<:Number};
Tuple{Int, Float64} <: NTuple{2, Number} = true
Tuple{Int, Float64} <: NTuple{2, <:Number} = false

julia> @show NTuple{2,   Number} <: NTuple{2,   Any}
       @show NTuple{2,   Number} <: NTuple{2, <:Any}
       @show NTuple{2, <:Number} <: NTuple{2,   Any}
       @show NTuple{2, <:Number} <: NTuple{2, <:Any};
NTuple{2, Number} <: NTuple{2, Any} = true
NTuple{2, Number} <: NTuple{2, <:Any} = false
NTuple{2, <:Number} <: NTuple{2, Any} = true
NTuple{2, <:Number} <: NTuple{2, <:Any} = true

Entertainingly, the show method prints the anonymous variable name:

julia> NTuple{2,<:Number}
Tuple{var"#s1", var"#s1"} where var"#s1"<:Number

Seems unrelated to the OP though.

I’m still a bit confused:

julia> Number<:Number
true

julia> NTuple{2,Number} <: NTuple{2,<:Number}
false

julia> NTuple{2,<:Number}
Tuple{var"#s52", var"#s52"} where var"#s52"<:Number

Does this help?

julia> Tuple{Number,Number} <: Tuple{T,T} where T<:Number
false

julia> Tuple{Number,Number} >: Tuple{T,T} where T<:Number
true
1 Like

Oh, I see, thanks!

OK, here seems to be some hint: the parameter T<:Number can not only be a numerical type but also be a union of numerical types:

julia> const UNTuple{N,T} = Union{NTuple{N,T},NamedTuple{M,NTuple{N,T}} where M} where T<:Number
Union{Tuple{Vararg{T, N}}, NamedTuple{M, Tuple{Vararg{T, N}}} where M} where {N, T<:Number}

julia> Tuple{Int,Float64} <: UNTuple{2}
true

julia> Tuple{Int,Float64} <: UNTuple{2,T} where T<:Number
true

julia> Tuple{Int,Float64} <: UNTuple{2,Union{Int,Float64}}
true

julia> Union{Int,Float64}<:Number
true

However, this is not the case for NTuple:

julia> Tuple{Int,Float64} <: NTuple{2,T} where T<:Number
false

julia> Tuple{Int,Float64} <: NTuple{2,Union{Int,Float64}}
true

It seems that NTuple{N,T} is defined such that T cannot be Union{Int,Float64} unless explicitly forced. So my question would be: how to force T<:Number to be a concrete numerical type?

See Types · The Julia Language, in contrast to other types, tuple types are covariant. EDIT: maybe unrelated, but still good to remember.

3 Likes

I’ll just give my two cents to see if I got the full picture myself. Maybe it can help anyone else too (or clear up my misconceptions!).

I think what happens in that last example is that NTuple{2,T} where T<:Number represents Tuple{T,T} where T <: Number. The type of any object which will be checked against that tuple type has to be a concrete type. Hence any instance of Tuple{Int, Float64} will have exactly that type. Since Int and Float64 are not the same T, we have that Tuple{Int,Float64} <: NTuple{2,<:Number} is false. Note that T<:Number is forced to be a concrete, numerical type. It’s just that it has to be the same concrete type in both entries of the tuple.

That NTuple{2,Union{Int,Float64}} works is due to the covariance of Tuple types, as @simsurace pointet out. Note that Union{Int,Float64} is not a concrete type, so Tuple{Int,Float64} <: NTuple{2,Union{Int,Float64}} is true (but there can be no instance of that type on the right).

Neither NTuple{2,Union{Int,Float64}} <: NTuple{2,<:Number} nor NTuple{2,<:Number} <: NTuple{2,Union{Int,Float64}} are true since there are tuples which will fall into one type, but not into the other.

When thinking about types, I found this video pretty helpful which was posted somewhere else on this discourse. Types and subtyping essentially works like set and subset calculations in mathematics. A type union is basically a union set.

2 Likes

Thank you all for your input. I still get confused by the following very simple example

julia> Tuple{Int,Float64}<:Union{Tuple{T,T}, Vector{T}} where T<:Number
true

julia> Tuple{Int,Float64}<:Union{Tuple{T,T}, Vector} where T<:Number
false

julia> (Union{Tuple{T,T}, Vector{T}} where T<:Number) <: (Union{Tuple{T,T}, Vector} where T<:Number)
false

which does not make any sense to me, as the first union should be a subset of the second union, shouldn’t it?

1 Like

It has to be the same type, but not necessarily concrete:

julia> (NTuple{N,T} where {N,T<:Real}) <: NTuple{N,T} where {N,T<:Number}
true

The comparison of concrete types happens naturally when you call isa on an instance, which is of course concrete.

It does seem related to this though: Diagonal Types - More about types · The Julia Language.

Yes. Hence, why this seems like a bug. It’s some sort of interaction between covariant and invariant types.

To illustrate, this works correctly:

julia> let T = Union{Tuple{T,T}, Tuple{T,T,T}} where T<:Number
           @show Tuple{Int,Int}         <: T
           @show Tuple{Int,Float64}     <: T
           @show Tuple{Int,Int,Int}     <: T
           @show Tuple{Int,Int,Float64} <: T
       end;
Tuple{Int, Int} <: T = true
Tuple{Int, Float64} <: T = false
Tuple{Int, Int, Int} <: T = true
Tuple{Int, Int, Float64} <: T = false

but this doesn’t:

julia> struct Foo{X,Y} end
       let T = Union{Tuple{T,T}, Tuple{T,T,T}, Foo{T,T}} where T<:Number
           @show Tuple{Int,Int}         <: T
           @show Tuple{Int,Float64}     <: T
           @show Tuple{Int,Int,Int}     <: T
           @show Tuple{Int,Int,Float64} <: T
           @show Foo{Int,Int}           <: T
           @show Foo{Int,Float64}       <: T
       end;
Tuple{Int, Int} <: T = true
Tuple{Int, Float64} <: T = true
Tuple{Int, Int, Int} <: T = true
Tuple{Int, Int, Float64} <: T = true
Foo{Int, Int} <: T = true
Foo{Int, Float64} <: T = false
3 Likes

Yes, that’s what I meant, I think my wording wasn’t right. I just tried to think of the subsetting as “can I find an instance that would fall into one type, but not the other”.

Ah, now I see. Thanks for spelling it out! It does seem pretty weird …

Not sure it’s a bug though. In the section on diagonal types and equality constraints of the manual you posted, it says

f(a::Array{T}, x::T, y::T) where {T} = ...

In this case, T occurs in invariant position inside Array{T}. That means whatever type of array is passed unambiguously determines the value of T – we say T has an equality constraint on it. Therefore in this case the diagonal rule is not really necessary, since the array determines T and we can then allow x and y to be of any subtypes of T. So variables that occur in invariant position are never considered diagonal. This choice of behavior is slightly controversial – some feel this definition should be written as

Consider this example which should match our discussion:

julia> Tuple{Number, Number} <: Tuple{T, T} where T <: Number
false

julia> Tuple{Number, Number} <: Union{Tuple{T, T}, Vector{T}} where T <: Number
true

As soon as we pack a parametric type in the set which allows us to identify T exactly (even as an abstract type), i.e. not a tuple type, the constraint on the tuple types having diagonal parameters is lifted.

In this example we have then

julia> Tuple{Number, Number} <: (Union{Tuple{T,T}, Vector{T}} where T<:Number)
true

julia> Tuple{Number, Number} <: (Union{Tuple{T,T}, Vector} where T<:Number)
false

but also

julia> Vector{String} <: (Union{Tuple{T,T}, Vector{T}} where T<:Number)
false

julia> Vector{String} <: (Union{Tuple{T,T}, Vector} where T<:Number)
true

so they are not the same set (EDIT: nor subsets of each other) and I think it works as intended (according to the manual).
But it’s definitely a pretty subtle point! :sweat_smile:

2 Likes

This is THE point. Thanks @Sevi !

1 Like

The point that this misses, however, is that although this behavior is appropriate when the outside container is a Tuple, it’s not appropriate when the outside container is a Union.

Namely, as part of a Tuple, the invariant type must be packed in alongside the covariant Tuple types, and therefore can be expected to specify their type parameter. But as part of a Union, the invariant type may be missing (as is the case here), in which case it does no good to expect it to specify the parametric type for the other covariant types—how could it?

Thus, I maintain that this seems like a bug.

1 Like

I see your point (that the discussion in the docs is focused on tuple types, not on union types). And as I said, I’m not entirely sure if this is intended or not (but more and more, based on the discussion below).

What exactly is the behavior you would expect?


I don’t get this. When we put the type parameter in the union and use the same parameter in all places, they better all be the same types over which we take the union. If we put no parameter that occurs in a tuple also in a invariant position (like Union{Tuple{T, T}, Vector}) within the union, the tuples are still restricted to the diagonal types and nothing unexpected happens.

Reading this type union

Union{Tuple{T,T}, Vector{T}} where T <: Number

I would always expect that T goes over all types which are subtypes of Number, and that there can be no T that appears in some of the Vector, but not in the Tuple and vice versa.

The only question in my mind is whether abstract types are included in the union or not.

  • Tuple{T,T} might suggest that only concrete types T <: Number are included, because in isolation, Tuple{T,T} where T<:Number allows only concrete types
  • but Vector{T} where T<:Number doesn’t exclude any abstract types for T in any other context (that I can think of right now)

I guess the question is, which behavior should take precedence over the other (the covariant parameters in the tuple types or the invariant ones). Reading the docs, it sounds to me like restricting the parameters for the tuples is treated as the exception, since it is required for matching function signatures in a meaningful way. But apart from that, the type parameters are always(?) invariant and unions range over all possible types of the parameter.

And more importantly, a (perhaps the?) basic property of unions should be that they grow as we add things.

With the current behavior

Vector{Number} <: Union{Tuple{T,T}, Vector{T}} where T <: Number

is true, which is what I would expect, since in any other situation Vector{T} where T <: Number allows abstract types for T. If adding Vector{T} to the union should enforce that T iterates over all subtypes of Number and is replaced in all three places where it occurs without changing the behavior of the tuple types then Vector{Number} <: Union{Tuple{T,T}, Vector{T}} where T <: Number should be false.

I think this would also imply that @jinfreedom’s example relation

(Union{Tuple{T,T}, Vector{T}} where T <: Number) <: (Union{Tuple{T,T}, Vector} where T <: Number)

would be true. This looks reasonable to me, but at the same time, the following would then be false (e.g. Number would be contained in the left, but not in the right):

(Union{T, Vector{T}} where T <: Number) <: (Union{T, Vector{T}, Tuple{T, T}} where T <: Number)

Should adding a Tuple to an arbitrary type union make the whole union smaller than just leaving it away?


The only other option I see would be that the tuple types are treated as they behave in isolation (only including concrete/diagonal types) and the vector type as well (including also abstract types), but that is just

Union{Tuple{T,T}, Vector{S}} where {S<:Number,T <: Number}

tl;dr If T should be the same T in all places in the union, we should rather “widen” the tuple types instead of restricting the other types to ensure (semantically) that unions grow when things are added.

Simple. I would expect that all instances of T are the same exact type (and therefore, T cannot be an abstract type if it’s a Tuple type parameter). This means I agree with this part of the devdocs:

slightly controversial – some feel this definition should be written as

f(a::Array{T}, x::S, y::S) where {T, S<:T} = ...

I didn’t hold this view before—I had thought the current behavior was ok—but I changed my mind.

Examples:

This should be (true, false, true, false, false, true, true):

julia> let T=Union{Tuple{T,T}, Vector{T}} where T <: Number
           Tuple{Int,Int} <: T,
           Tuple{Int,Float64} <: T,
           Union{Tuple{Int,Int}, Vector{Int}} <: T,
           Union{Tuple{Int,Float64}, Vector{Int}} <: T,
           Union{Tuple{Int,Float64}, Vector{Number}} <: T,
           Vector{Int} <: T,
           Vector{Number} <: T
       end
(true, true, true, true, true, true, true)

I previously believed it was ok for the 5th item should be true, but I changed my mind.

Meanwhile, this should be (true, true):

julia> let T=Union{Tuple{T,T}, Vector} where T <: Number
           (Union{Tuple{T,T}, Vector{T}} where T <: Number) <: T
       end,
       let T=Union{T, Vector{T}, Tuple{T, T}} where T <: Number
           (Union{T, Vector{T}} where T <: Number) <: T
       end
(false, true)

The reason I changed my mind, is because I needed to in order for that false to be true. Once you make that decision, to be consistent and demand that T refer to the exact same type everywhere that T is valid, then all of these problems get cleaned up simultaneously, and the OP question would not have occurred.

Isn’t that a contradiction?

If adding Tuple to the union restricts the parameter to being concrete, then this would turn into (true, false). The way to make both true would be to restrict types parameters always to range over concrete types.

In the current behavior and in your suggestion, adding one type will dictate what happens to the others in the union – personally, I think allowing abstract types in the tuple when the parameter appears also in an invariant position is the more sensible choice.