Best practices to add more than one bound to type parameters

MWE:

julia> const TwoOrThreeTuple{T} = Union{NTuple{2, T}, NTuple{3, T}}
Union{Tuple{T, T}, Tuple{T, T, T}} where T

julia> struct myT{T, N, NTuple{N, T}<:V<:TwoOrThreeTuple{T}}
       a::V
       end

julia> myT((1,2))
ERROR: UndefVarError: `N` not defined

I want to capture both N and T from the type of .a while limiting the possible number of elements in it. But I couldn’t do it by adding both upper and lower constraints on V.

I would like to achieve this goal by ONLY applying constraints on the definition of myT, i.e., the composite type itself, not relying on the method signatures of its constructors. Is there a way to achieve it?

Thanks!

1 Like

I tried to achieve the double constraint (NTuple{N, T} and TwoOrThreeTuple{T}) by defining an intermediate abstract type alias. However, I encountered inconsistent result from Julia’s compiler:

julia> abstract type MyType{V} <: Any end

julia> const TwoOrThreeTuple{T} = Union{NTuple{2, T}, NTuple{3, T}}
Union{Tuple{T, T}, Tuple{T, T, T}} where T

julia> const MyTwoThree{T, V<:TwoOrThreeTuple{T}} = MyType{V}
MyTwoThree{T} where T (alias for MyType{V} where {T, V<:Union{Tuple{T, T}, Tuple{T, T, T}}})

julia> struct myT2{T, N, V<:NTuple{N, T}} <: MyTwoThree{T, V}
           a::V
       end

julia> myT2((1,))
myT2{Int64, 1, Tuple{Int64}}((1,))

julia> MyTwoThree{Int, Tuple{Int}}
ERROR: TypeError: in MyType, in V, expected V<:Union{Tuple{Int64, Int64}, Tuple{Int64, Int64, Int64}}, got Type{Tuple{Int64}}
Stacktrace:
 [1] top-level scope
   @ REPL[6]:1

As you can see, MyTwoThree should forbid any types that are not subtypes of TwoOrThreeTuple from being its second parameter (V). In fact, if you try to write such an illegal instance of MyTwoThree, it can correctly detect the issue. However, when I tried to construct a myT2 with an illegal parameter V::Tuple{Int}, the construction bypassed this check.

I’m unsure whether this is considered a “bug”, or another “feature” of Julia’s type system. I am concerned either way.

1 Like

Not an expert on the type system, but I think this the answer is no. Specifically if you want to do some arithmetic with the type parameters (if I understand correctly, here you would like to check whether the number of elements in the tuple is between some bounds).

What’s the reason why you don’t want to use (inner) constructors? It seems just as general to me as a potential type-based solution:

struct myT3{T, N, V}
    a::V
    
    function myT3(a::V) where {N, T, V <: NTuple{N, T}}
        if 1 < N < 4
            return new{T, N, V}(a)
        else
            error("Bad N = $(N)!")
        end
    end
end
julia> myT3((1,))
ERROR: Bad N = 1!

julia> myT3((1,2,))
myT3{Int64, 2, Tuple{Int64, Int64}}((1, 2))

julia> myT3((1,2,4,))
myT3{Int64, 3, Tuple{Int64, Int64, Int64}}((1, 2, 4))

julia> myT3((1,2,4,5))
ERROR: Bad N = 4!

In a broader sense, this specific example also wouldn’t work with lower type bounds, since NTuple{N, T} where {N, T} would never be a subtype of the upper bound that you gave (a union with two specific values of N), so anything like lower <: X <: upper cannot be true if I understand correctly. So the “extra” constraint NTuple{N,T} is not really a constraint in the first place?


But I have no idea why your second example doesn’t work, since it does work as (presumably) expected for a slightly stripped down example based on your code:

abstract type AbstractContainer{V} end

abstract type RestrictedContainer{V<:Real} <: AbstractContainer{V} end 

struct Container{T <: Number} <: RestrictedContainer{T}
    n::T
end
julia> Container(1)
Container{Int64}(1)

julia> Container(1 * im)
ERROR: TypeError: in RestrictedContainer, in V, expected V<:Real, got Type{ComplexF64} 

julia> Container("a")
ERROR: MethodError: no method matching Container(::String)

Closest candidates are:
  Container(::T) where T<:Number

The last one shows that the <: Number is triggered first when passing anything else than a Number. Only afterwards is the <: Real applied. But as above, since Number >: Real, the restriction to Number is not really necessary anyway.

2 Likes

This doesn’t mean what you think it means, the lower bound just gets ignored AFAIK. Compare:

julia> T where {T>:Int}
Any

julia> Vector{T} where {T>:Int}
Vector{T} where T>:Int64 (alias for Array{T, 1} where T>:Int64)

To partially work around this, wrap the tuple within another struct:

const TwoOrThreeTuple{T} = Union{NTuple{2, T}, NTuple{3, T}}
struct W{P}
    p::P   
end
struct myT{T, N, NTuple{N, T}<:V<:TwoOrThreeTuple{T}}
    a::W{V}
end

This still fails at construction, but the static parameter matching of N succeeds, at least. Compare with @code_warntype. Also, I believe the reason it fails to match for V is a dispatch bug:

2 Likes
2 Likes

As was alluded by some of the above posters, parameter constraints are limited in their capability and functionality. I suppose the reason we don’t put more effort into it is that they aren’t very useful. The main thing (that I can think of) that they do that an inner constructor cannot is restrict the mere concept of a type:

julia> struct Foo{T<:Real} end

julia> Foo{Vector} # we cannot conceptualize this type
ERROR: TypeError: in Foo, in T, expected T<:Real, got Type{Vector}

But that has limited use because you won’t do much because types are usually instantiated.

# define type with inner constructor to impose type constraints
julia> struct Bar{T}; Bar{T}() where T = T<:Real ? new{T}() : error(T, " is not <:Real"); end

julia> Bar{Vector} # this does not use the constructor, so the type can exist
Bar{Vector}

julia> Bar{Vector}() # cannot create an instance by usual means
ERROR: Vector is not <:Real

julia> only(Array{Bar{Vector}}(undef)) # smuggle an unconstructed instance into existence
Bar{Vector}()

So while I was able to create an instance of this un-instantiable type in a roundabout way by reading it out of an undef Array (a big no-no, especially if this type had actual data fields), that isn’t something one does often or by accident. Note that any attempt to construct an instance will still fail.

The ability to talk about the type Bar{Vector} is not usually an issue because you’re mostly concerned with instances of types. Because Julia’s compiler specialized on specific runtime types, bounds on type parameters (especially lower bounds) provide almost no value to the compiler, so you aren’t missing much except for some not-commonly-useful guardrails.

I strongly disagree with the above comments, but won’t reply here as that discussion is off-topic here. IMO a mod should move these to a new thread, something like “Usefulness of type parameter constraints”, to prevent further derailment.

My understanding is that NTuple{N, T} in {} of a parametric type (e.g., myT) is characterized by every possible concrete value of N and T. Only the entire signature of myT is considered a UnionAll. Thus, it is possible to set aN NTuple{N, T} as a lower bound of another TypeVar (e.g., X).

An example to support my conjecture is that the below type t is legal to construct:

julia> t = Array{T, N} where {N, T0<:Union{Integer, Float64}, NTuple{N, T0}<:T<:Union{Tuple{Real}, Tuple{Real, Real}}}
Array{T, N} where {N, T0<:Union{Float64, Integer}, Tuple{Vararg{T0, N}}<:T<:Union{Tuple{Real}, Tuple{Real, Real}}}

julia> [(1,)] isa t
true

julia> [(1.0,)] isa t
true

julia> [(1,2);;] isa t
true

julia> [(1,2);;;] isa t 
false

Looking at the last line of evaluation, in order for it to output false, the lower bound NTuple{N, T0} must be checked by the type system because only its parameter N is shared with Array{T, N}.

We can further verify such a claim by defining and checking an alternative type t2:

julia> t2 = Array{T, N} where {N, T<:Union{Tuple{Real}, Tuple{Real, Real}}}
Array{T, N} where {N, T<:Union{Tuple{Real}, Tuple{Real, Real}}}

julia> [(1,2);;;] isa t2
true

However, I would soon encounter another “inconsistent” result from the type system if I try something like:

julia> [(Float32(1.0),)] isa t
true

Which somehow also matches with @nsajko’s statement:

Anyway, the type system often behaves “weirdly” when it’s “entangled” with type parameters. And we definitely lack the proper official explanation regarding this usage. The best source I could find is here when UnionAll is explained.

Go to a slightly off-tangent but related topic to the specific issue we discussed here. Overall, I agree with @mikmoore in the sense that what we saw is the consequence of the current situation that

However, my personal response to the status quo of how Julia’s type system handles type parameters aligns much more with @nsajko than @mikmoore:

Particularly, in my domain of using Julia for scientific programming, we almost always have to construct many composite types (struct) that contain seemingly many fields of different types but exhibit high levels of “type coupling.” This means that with proper arithmetic (like [1], [2], [3]), we can actually reduce the number (and range) of type parameters tied with the constructed composite types.

IMHO, this has the potential to help reduce compilation time, whether it’s just for TTFX (time to first X) or runtime dispatch. Your parametric type wouldn’t have to face “combinatorial explosion” of type instances while still being able to keep track on the concrete types of its fields. I am aware that it’s best to avoid runtime dispatch and opt for AOT compilation, but sometimes, it is unavoidable when you are dealing with many composite types.

in conclusion, I believe we should really put more effort into optimizing the functionality of constraining/controlling type parameters. At least it helps us sort out solutions/fixes to edge cases like what I originally posted here. The type system is after all the backbone of Julia.

Thank you for your reading!

2 Likes

You’re right, I misunderstood how parametric types are interpreted in the type bounds. And there definitely seems to be some inconsistencies, as you showed. I’m also confused now about whether this should be supposed to work or not:

extract_eltype1(a::V) where {T, V >: Vector{T}} = println(T, " ", V)
extract_eltype2(a::V) where {T, V <: Vector{T}} = println(T, " ", V)

The first one gives UndefVarError: T not defined whereas the second one matches T = Int64 and V = Vector{Int64}. It feels like this is also what one could reasonably expect from the first one?


I am still genuinely curious about the particular use case. Is there a scenario where putting the type bounds in constructors would not give you the same end result?
Of course it would duplicate a lot of code and make things overly verbose and likely harder to maintain (it would be really nice if we can have “bounds checking” at the level of types working “correctly”), but one can still avoid the combinatorial explosion of type instances if instances of the nonsensical/unnecessary types can never be constructed, right? I’m just trying to learn more about the bigger picture here.

1 Like

Taking a step back, it seems like there’s another way to accomplish your goal, by using UnionAll to add a second supertype constraint:

struct myTImpl{T, N, V<:NTuple{N, T}}
    a::V
end

const TwoOrThreeTuple{T} = Union{NTuple{2, T}, NTuple{3, T}}

const myT = myTImpl{T, N, V} where {T, N, V<:TwoOrThreeTuple{T}}

function myT(a::V) where {T, N, V<:NTuple{N, T}}
    myT{T, N, V}(a)
end

NB: given that you’re interested in defining types with redundancy among their type parameters, you might benefit from my package TypeCompletion.jl.

1 Like

My brain is on vacation, but why not

struct myT{T, V<:TwoOrThreeTuple{T}}
    a::V
end

?

See:

I saw that, and they are both present in the type parameters.

N is not explicitly presented, which is what I really wanted. Because in practice, I want something like:

struct myT{T, N, V<:NTuple{N, T}}
    a::V # V should also be constrained to subtypes of TwoOrThreeTuple{T}
    b::Array{T, N}
    c::Tuple{Int, Vararg{V, N}}
    # more fields...
end

What is it you need the ‘bare’ N for? Even without that, you can dispatch on N, and you can retrieve it statically, just the same. What’s the usage you have in mind?

Adding redundant type parameters is not something one normally seeks out, so there must be something you need it for. I apologize if I missed this in your previous posts.

I don’t know if you have dealt with many composite types with more than 4 type parameters, but from my experiences, they can dramatically increase the multiple dispatch’s overhead.

This is probably the best solution so far. Thanks!

I must confess, for my own taste, I don’t really like to make myT a type alias on top of another struct type myTImpl. I intended myT to be a public composite type with a docstring explaining its fields and functionalities. With this solution, I’ll have to introduce myTImpl but still tell the users, “This is not a fully public API, and you should always stick to methods named myT:sweat_smile:.

But it’ll do the job.

Oh, that looks interesting! I’ll check it out! Thanks!!

1 Like

With respect to your MWE, you are trying to add more type parameters. Do you have a more illustrative MWE, where this approach simplifies the type signature?

How about not mentioning myTImpl in the docs at all? Just make it private API.