Partially parametrized external constructor of a parametric type

Define a parametric type with parameters S and T (S is intended to be an integer, and T is intended to be a floating-point number):

struct FooBar{S,T}
    x::S
    y::T

    FooBar{S,T}(x,y) where {S,T} = new(x,y)
end

Define an external constructor with only the second parameter T:

FooBar{T}(y) where {T} = FooBar{Int,T}(0,y)

The external constructor works:

julia> FooBar{Float64}(1.0)
FooBar{Int64, Float64}(0, 1.0)

Now, define the same parametric type and external constructor, except that S and T are limited with their supertypes as S<:Integer and T<:AbstractFloat:

struct Foo{S<:Integer,T<:AbstractFloat}
    x::S
    y::T

    Foo{S,T}(x,y) where {S,T} = new(x,y)
end

Foo{T}(y) where {T} = Foo{Int,T}(0,y)

The external constructor generates an error:

julia> Foo{Float64}(1.0)
ERROR: TypeError: in Foo, in S, expected S<:Integer, got Type{Float64}
Stacktrace:
 [1] top-level scope
   @ REPL[14]:1

Here, the parameter T is the first (and only) parameter used in the signature of the external constructor. Somehow Julia seems to think that it should also be the first parameter of the type definition and therefore it has to be <:Integer.

To verify this hypothesis, define another parametric type and external constructor, but use the first parameter S of the type definition in the signature of the external constructor:

struct Bar{S<:Integer,T<:AbstractFloat}
    x::S
    y::T

    Bar{S,T}(x,y) where {S,T} = new(x,y)
end

Bar{S}(x) where {S} = Bar{S,Float64}(x,1.0)

Now the external constructor does not generate an error:

julia> Bar{Int}(0)
Bar{Int64, Float64}(0, 1.0)

Is this a bug in Julia? Here is the version info:

julia> VERSION
v"1.9.2"
3 Likes

Type parameters are not named, they are positional only. So, the convention that Julia adopts is that you can only drop trailing type parameters. You can’t choose to, say, omit the first parameter and only use the second parameter. Here is an example that demonstrates this:

julia> struct Foo{A,B,C} end

julia> Foo{Int}
Foo{Int64,B,C} where C where B

julia> Foo{Int,String}
Foo{Int64,String,C} where C

Note that I ran that example on Julia 1.4. At some point the printing was changed, which obfuscates what is going on. Here is what you see on Julia 1.8:

julia> struct Foo{A,B,C} end

julia> Foo{Int}
Foo{Int64}

julia> Foo{Int,String}
Foo{Int64, String}

I don’t know how to recover the full UnionAll type printing on recent versions of Julia.

3 Likes

Use Base.unwrap_unionall:

struct Foo{A,B,C} end

Foo{Int} |> Base.unwrap_unionall
# will produce: Foo{Int64, B, C}

Foo{Int,String} |> Base.unwrap_unionall
# will produce: Foo{Int64, String, C}
1 Like

Thanks! I actually created a separate thread for that question:

1 Like

I know this, but my question is not about the order of the parameters of a type: the question is about the order of the parameters of a constructor. That is why I gave the first example FooBar, for which it was shown possible to pass the first parameter T of the constructor to the second parameter of the type:

FooBar{T}(y) where {T} = FooBar{Int,T}(0,y)
1 Like

It’s the same underlying issue. Type objects are callable. The methods on type objects are called constructors. So FooBar{Float64}(y) is really shorthand notation for this:

(FooBar{Float64,T} where T)(y)

So when you have the <:Integer constraint on the first type parameter, FooBar{Float64}(y) will fail because FooBar{Float64,T} where T is not a subtype of FooBar{S,T} where {S<:Integer, T<:Float}. Thus, the type object cannot be created, and you get a failure before the constructor method is even called.

We can see this in action (on Julia 1.6, which has better printing):

julia> struct FooBar{S,T}
           x::S
           y::T
       
           FooBar{S,T}(x,y) where {S,T} = new(x,y)
       end

julia> FooBar{T}(y) where {T} = FooBar{Int,T}(0,y)

julia> @which FooBar{Float64}(1.0)
(FooBar{T, T1} where T1)(y) where T in Main at REPL[2]:1

Here you can see that the parameter T in the constructor you defined is used as the first parameter of FooBar, even though you use the value of that parameter inside the method to set the value of the second parameter in the constructed object.

6 Likes

I see. Then should I stop defining constructors like FooBar{T}(y) where {T} = FooBar{Int,T}(0,y) that use first parameters of the constructor as the later parameters of the type? Though this is doable if I don’t specify the supertypes of the type parameters, and convenient sometimes, it feels like exploiting a language loophole after reading your explanation.

2 Likes

Yeah, I would avoid doing that. As you said, it works if the type parameters are not constrained, but it clashes with the semantics of FooBar{Float64} being equivalent to FooBar{Float64, T} where T.

1 Like

This is a big reason I wish it wasn’t possible to write partial parameters as a shorthand for iterated unions with unfixed trailing parameters, it’s very natural to wrongly assume the syntax instead means the parameters are like function arguments to a callable type-name. Even if you make a method for types that the struct definition made impossible, an error will be thrown at the impossible types like Foo{Float64}, before a call can even happen. Function arguments are the way to go, and make sure the callable is not impossible:

julia> Foo(::Type{T}, y) where {T} = Foo{Int,T}(0,y)
Foo

julia> Foo(Float64, 1)
Foo{Int64, Float64}(0, 1.0)
2 Likes

A tangential question, but is this different from the following?

Foo(T::Type, y) = Foo{Int,T}(0,y)

Both seem to generate the same result for Foo(Float64, 1).

I made the T a parameter of the method shared with the parameter of the special Type{T}, it’s usually used to force the compiler to compile separate native code for each type T; Julia doesn’t automatically specialize on functions, types, or varargs that are only passed as parameters or arguments instead of being called themselves. Bit messy but you can see the effects:

julia> Foo(T::Type, y) = Foo{Int,T}(0,y)
Foo

julia> methods(Foo)
# 1 method for type constructor:
[1] Foo(T::Type, y) in Main at REPL[12]:1

julia> methods(Foo)[1].specializations # nothing compiled yet
svec()

julia> Foo(Float64, 1), Foo(Float32, 1), Foo(Float16, 1);

julia> filter(!isnothing, collect(methods(Foo)[1].specializations))
1-element Vector{Any}:
 MethodInstance for Foo(::Type, ::Int64)

julia> Foo(::Type{T}, y) where {T} = Foo{Int,T}(0,y)
Foo

julia> methods(Foo)
# 1 method for type constructor:
[1] Foo(::Type{T}, y) where T in Main at REPL[17]:1

julia> methods(Foo)[1].specializations
svec()

julia> Foo(Float64, 1), Foo(Float32, 1), Foo(Float16, 1);

julia> filter(!isnothing, collect(methods(Foo)[1].specializations))
3-element Vector{Any}:
 MethodInstance for Foo(::Type{Float16}, ::Int64)
 MethodInstance for Foo(::Type{Float32}, ::Int64)
 MethodInstance for Foo(::Type{Float64}, ::Int64)
3 Likes