Unassigned type parameters in `UnionAll` of `Tuple` do not refer to concrete types in edge cases

frankwswang
Sun, 26 Jan 2025 07:02:36 +0000
General Usage

Consider a simple example where I define a type alias for (non-empty) Tuple:

julia> const MyTuple1{T, N} = Tuple{T, Vararg{T, N}}
Tuple{T, Vararg{T, N}} where {T, N}

Even when the type parameter T is not assigned, the UnionAll-type MyTuple1 refers to all the instances of MyTuple1{T, N} where T and N are both concrete type parameters (meaning they are either concrete types or literal value like 1). This can be verified with the following example:

julia> t1 = Tuple{Int, Float64}
Tuple{Int64, Float64}

julia> t1 <: MyTuple1{Real}
true

julia> MyTuple1{Real} == Tuple{Real, Vararg{Real}}
true

julia> t1 <: MyTuple1
false

When T in MyTuple1 was unassigned, it included all possible concrete types but not abstract types like Real. Hence, even though t1 is a subtype of MyTuple1{Real}, it’s not a subtype of MyTuple1.

Such an implicit constraint on type parameters still holds when one type parameter is used as an upper bound for another type parameter:

julia> const MyTuple2{U, T<:U, N} = Tuple{T, Vararg{T, N}}
Tuple{T, Vararg{T, N}} where {U, T<:U, N}

julia> t1 <: MyTuple2
false

julia> t1 <: MyTuple2{Real}
false

julia> t1 <: MyTuple2{Real, Real}
true

Even when the upper-bound parameter U is set to Real, if T is unassigned, it does not include Real, unless I manually set it to be Real.

However, there are edge cases that break this implicit constraint on type parameters.

For example, when the type parameter is used as a lower bound, the bounded type parameter seems to be allowed to include abstract types when unassigned:

julia> const MyTuple3{U, T>:U, N} = Tuple{T, Vararg{T, N}}
Tuple{T, Vararg{T, N}} where {U, T>:U, N}

julia> t1 <: MyTuple3
true

So, my question is: Is this behavior expected?

Additionally, what’s more strange is the following paradoxical behaviors:

julia> t1 <: MyTuple3{Real}
true

julia> t1 <: MyTuple3{Float64}
false

julia> t1 <: MyTuple3{Float64, Real}
true

It appears that by setting U to be a concrete type Float64, T fell back to referring to all possible concrete types. I don’t know if this will be classified as a bug, but it’s definitely a bizarre behavior of the type system (on Julia 1.11.3).

Okay, what about having type parameters defined for both an upper and a lower bound? Here is the result:

julia> const MyTuple4{U, L<:U, L<:T<:U, N} = Tuple{L, Vararg{T, N}}
Tuple{L, Vararg{T, N}} where {U, L<:U, L<:T<:U, N}

julia> t1 <: MyTuple4
true

julia> t1 <: MyTuple4{Real}
true

julia> t1 <: MyTuple4{Real, Real}
true

julia> t1 <: MyTuple4{Real, Float64}
false

julia> t1 <: MyTuple4{Real, Real}
true

It seems that the “fall-back effect” of a concrete lower-bound parameter still persists, whereas when both U and L are unassigned, they do include abstract-type instances.

6 posts - 3 participants