Defining parametric unions with no parametric members

I am trying to understand the syntax and requirements for defining parametric union types. It seems that defining a parametric union doesn’t work unless at least one of its components is also parametric. Example:

Foo{T <: Real} = Union{
    Int,
    Float64,
    Vector{T},
}

Bar{T <: Real} = Union{
    Int,
    Float64,
}

fun_Foo(::Foo{T}) where {T} = println("foo!")
fun_Bar(::Bar{T}) where {T} = println("bar!")

fun_Foo(0)  #<--- works 
fun_Bar(0)  #<--- does not work

The reason is that Foo{Int} seems to be a valid type, but Bar{Int} is not. What is going on here?

The above is just an example to illustrate the problem, and I realise that in this example the solution is just to make Bar non-parametric. The real use case is that Bar is defining a collection of supported types that I gradually want to add to, and currently none of those types happen to be parametric. Future additions will be though, and I want to define the type such that it works either way.

I am using a workaround by defining something like

struct MyFakeType{T<:Real} end

and then defining

Bar{T <: Real} = Union{
    Int,
    Float64,
    MyFakeType{T}
}

This seems somehow unsatisfactory though.

Just two data points:

  • your code fails already at
fun_Bar(::Bar{T}) where {T} = println("bar!")

with

ERROR: LoadError: TypeError: in Type{...} expression, expected UnionAll, got Type{Union{Float64, Int64}}

on Julia 1.9.0-DEV.

  • The following modification seems to work here
Foo{T <: Real} = Union{
    Int,
    Float64,
    Vector{T}
}

Bar{T <: Real} = Union{
    Int,
    Float64
} 

fun_Foo(::Foo) = println("foo!")
fun_Bar(::Bar) = println("bar!")

fun_Foo(0)
fun_Bar(0)
2 Likes

Your parametric methods don’t use the parameter T. If you don’t need to use a parameter, you don’t need to write a parametric method e.g. fun_Bar(::Bar); the only reason I can think of for writing an unused parameter is to nudge the compiler to specialize over subtypes of Type, Function, and Vararg, which is only something you should try if you prove that specialization isn’t happening and specializing improves performance without excessively increasing compilation in your program.

If the methods did use the T, e.g. println("foo! ", T), then fun_Foo(0) would throw an ERROR: UndefVarError: T not defined because Int64 lacks a parameter to assign to T. If you use the parameter, then you should make sure the arguments’ concrete types have parameters. Annotating an argument with a parametric union that contains nonparametric types goes against that.

It appears that Foo{T <: Real} = Union{ Int, Float64, Vector{T}, } is equivalent to Foo = Union{ Int, Float64, Vector{T} } where {T <: Real}; it actually surprised me the first way is legal because I associate the {T <: Real} syntax to coming after where or to be in the header of struct definitions (which is an implicit where). So by that logic, Bar{T <: Real} = Union{ Int, Float64, } is equivalent to Bar = Union{Int, Float64} where T; in this case however, the where T is ignored because there’s no T, so Bar ends up as a nonparametric Union while Foo ends up as a parametric UnionAll.

Your parametric methods don’t use the parameter T . If you don’t need to use a parameter, you don’t need to write a parametric method e.g. fun_Bar(::Bar) ; the only reason I can think of for writing an unused parameter is to nudge the compiler to specialize over subtypes of Type , Function , and Vararg , which is only something you should try if you prove that specialization isn’t happening and specializing improves performance without excessively increasing compilation in your program.

I agree that the method doesn’t need to use the parameter in the example, and I was just trying to illustrate what seemed like the main problem in a minimal way. In the actual use case I have additional arguments that are also parametric, and I want everything that is of parametric type to be consistent.

Maybe it is helpful to explain the actual use case. I want to write a MathOptInterface wrapper for some solver, where the solver works for different data types as long as all data is of a common type. So I define my optimizer this way:

mutable struct Optimizer{T} <: MOI.AbstractOptimizer
   <whatever>
end

Now I want to declare which constraint types I can support. Some of those types are non-parametric (MOI.Zeros for example) but others have data attached and so are parametric (MOI.PowerCone{T} for example). Now I want to collect all of the types I support in a Union:

const MySupportedCones{T <: Real} = Union{
    MOI.Zeros,
    MOI.PowerCone{T}   #<--- removal causes errors
    }

If I have MOI.PowerCone{T} in this Union, I get the Foo situation above. If I comment it out, I get the Bar situation. At the moment it is out because the solver does not yet support that type, but I want to leave open the possibility that it will in some future version.

This matters because now I want to declare support for these cones, but I want (in the case of MOI.PowerCone{T}) for the type to match that of the Optimizer. So I do something like this:

MOI.supports_constraint(
    ::Optimizer{T},
    ::Type{<:MOI.VectorOfVariables},
    ::Type{<:MySupportedCones{T}}
) where {T} = true

This fails if I don’t include MOI.PowerCone{T} in the union, because then the T is ignored in the union definition. However, I can’t just put Type{<:MySupportedCones} as an argument in supports_constraint because I want to return false if I get a MOI.PowerCone{Float64} but the optimizer is an Optimizer{Float32} or whatever.

As I said in the first message, I can get around this by just declaring a fake type parametric on T to include in the Union. It’s harmless because I know that MOI will never ask me about it anyway, so the question is mostly trying to understand what is going on.

The actual use case just has the same ignored-where T issue as the simplified FooBar example, though it does help us see that the T exists to constrain both Optimizer{T} and MySupportedCones{T}, and that the T is not defined error isn’t an issue because you have Optimizer{T} to specify it. To relate it back to the FooBar example, fun_Foo(::Foo{T}, ::Vector{T}) where {T} = println("foo! ", T) would’ve worked for fun_Foo(0, [0.0]).

I don’t know why this happens, but I do have a guess. Instead of a parametric method, consider multimethods over different concrete parameters. The call f( [0] ) can pick between 2 methods f(::Foo{Int}) and a f(::Foo{Float64}). f(0) cannot because Int is shared between both sets of types, so you have to resolve the ambiguity with a f(::Int) method. However, if we let Bar be a UnionAll, the complete overlap of Bar{Int} and Bar{Float64} means the methods g(::Bar{Int}) and g(::Bar{Float64}) will always throw an ambiguity error, so it makes sense to force you to write g(::Bar) by forcing Bar to be a Union.

PS: Make sure your method has where {T<:Real} to match your cones. Even if you specified MySupportedCones{T <: Real}, the method’s where segment overrides the UnionAll’s. Example:

X{T <: Real} = Vector{T}
# equivalent to const X = Vector{T} where T<:Real
# X{Int} works, X{String} does not

f(::X{T}) where T<:AbstractString = T
# f(["hi"]) works, f([0]) does not

Y{T<:AbstractString} = X{T}
# also overrides to Vector{T} where T<:AbstractString

Thanks - I think I see the problem and the explanation makes sense. As a stopgap I will go with the fake parametric type as a placeholder since that seems to work and it’s only temporary anyway.

Yeah I had considered a couple harebrained schemes to get around it but turns out you really need to use the T to get a UnionAll. Only real suggestion I have is to use abstract type _Placeholder{T<:Real} end and never subtype it, so there’s no default constructor that could make some instance <: _Placeholder, whereas struct MyFakeType{T<:Real} end is given a constructor that can make instances.