`Union` constraints in abstract type declarations seem to break subtype relations

I’m currently writing a package, CliffordNumbers.jl, that makes the following abstract type declaration:

const BaseNumber = Union{Real,Complex}

abstract type AbstractCliffordNumber{Q<:QuadraticForm,T<:BaseNumber} <: Number
end

Clifford algebras are defined over real or complex numbers, and I wouldn’t want to include other objects that subtype Number outside of the Real and Complex types provided by Base, such as a Quaternion type. In the code, I define an alias to simplify this.

I’ve defined a concrete type:

struct CliffordNumber{Q,T,L} <: AbstractCliffordNumber{Q,T}
    data::NTuple{L,T}
end

but I get some very unexpected results when I work with subtyping relationships:

julia> supertype(CliffordNumber)
AbstractCliffordNumber{Q,T} where {Q,T}

I’d think that AbstractCliffordNumber would be sufficient, but the results get stranger from here:

julia> CliffordNumber <: AbstractCliffordNumber
false

julia> CliffordNumber{APS} <: AbstractCliffordNumber{APS}
false

julia> CliffordNumber{APS,Float64} <: AbstractCliffordNumber{APS,Float64}
true

From my inspection of the behavior, it seems like Julia may not infer that the type constraints on T in AbstractCliffordNumber{Q,T} propagate to its subtypes if T is a Union, because this works:

julia> CliffordNumber{APS,<:Any} <: AbstractCliffordNumber{APS}
true

julia> CliffordNumber{APS,<:Union{Real,Complex}} <: AbstractCliffordNumber{APS}
true

Is this a bug in the implementation of subtyping, or am I doing something unreasonable enough to generate these results?

What happens if you change BaseNumber to a non-Union type?

supertype computes a less constrained result because your CliffordNumber definition doesn’t restrict Q or T like AbstractCliffordNumber does. If you try to make a CliffordNumber type outside those constraints though, AbstractCliffordNumber does throw a TypeError, so this statement is still practically correct. Here’s another example of a parametric type and its parametric supertype’s type constraints not lining up:

julia> struct Realvec{T<:Real} <: AbstractVector{T} end

julia> supertype(Realvec)
AbstractVector{T} where T<:Real

Though in that case the constraint is narrower on the subtype’s parameter.

Same reasoning, CliffordNumber’s parameters are less constrained to the type system.

This one works nicely because you fixed the type parameters with the constraints mismatch.

Strange, I get false, which makes sense because the 2nd CliffordNumber parameter isn’t constrained. I didn’t install your package, I only copy-pasted struct QuadraticForm{P,Q,R} end; const APS = QuadraticForm{3,0,0} and then the code you posted, but AFAIK this shouldn’t make a difference because type definitions are set in stone.

This one works for me, and it makes sense because the 2nd CliffordNumber parameter has the same constraint as AbstractCliffordNumber.

As for why a subtype’s parameters doesn’t gain the constraints of its declared supertype’s parameters by default, it’s because you actually implicitly specified Any as the type constraints. It’s not treated differently from doing:

abstract type AB{T<:Real} end
struct B{T<:AbstractString} <: AB{T} end
#= same as specifying arbitrary where clause in iterated union,
which isn't restricted by an existing type's where clause,
in this case B{T} where T<:AbstractString also specifies
a supertype AB{T} where T<:AbstractString (matching T). =#

It’s more apparent there that you can’t simply “inherit” parameter constraints, you must compute the type intersection; in B’s case there is no valid type parameter. typeintersect is documented to be occasionally imprecise (though it works perfectly for typeintersect(AbstractString, Real) == Union{}), so checking for a valid parameter is done using the direct types’ and its supertypes’ constraints sequentially. If you don’t want to deal with intersection difficulties, manually replicate or narrow constraints in all the subtypes’ definitions. This is best practice because you don’t want to look up supertypes, which are possibly in other packages, just to know the parameter constraints.

1 Like

This explains it. This might be something that needs to be reflected in Julia documentation, because I couldn’t find anything on it in the Types section, even though it does cover parametric abstract types.

Just going to point out that I’ve opened up an issue on GitHub to have this behavior documented.

The one response does suggest enforcing the constraint through typeintersect.

1 Like

Unfortunately I don’t think that’ll work because of typeintersect’s occasional imprecision, checking type parameter constraints up the supertype chain really is more reliable, and that’s not something you can really store in 1 UnionAll. There could be a method that prints the sequence of parameter constraints, for reflection’s sake.

Besides, making types have straightforward type parameter constraints in the source code still requires manual design, for example struct CliffordNumber{Q,T,L} would be misleading whether or not the resulting UnionAll instance incorporated perfected typeintersect constraints. That would indeed be nice to document somewhere.

Feel free to bring these up in the issue or just link this thread in your post, that’s done sometimes for brevity’s sake.