Parametric Types with Union

The type parameters are invariant, the actual type will subtype just fine e.g. Point{Int} <: AbstractPoint{Int}.

Your use of union types is correct, so let’s ignore the Dict for now, as the type conversion is intended for the Matrix. Unlike most types, Tuples are specially limited and thus allowed to have type parameter covariance, so Tuple{Species, Float64} <: Tuple{AbstractSpecies, Float64}. And as you note, the error is that subtyping of type parameters don’t extend to subtyping of the types, including Matrix. There are a couple ways to go here:

  1. Full flexibility of inputs, minimal compilation of methods for them. Your matrix is currently automatically determining the element type parameter Species because all the test inputs are Species. However, you might throw in a (SpeciesX("newA"), 3.0) in practice, in which case the element type will change. You could let base Julia try to figure out AbstractSpecies from the inputs, but if you know that’s the constraint, you can and should specify it manually Tuple{AbstractSpecies, Float64}[(a,1.0) (b,2.0)]. Now the MassAction call will work.
  2. More type constraints, more optimized compilation of methods for each concrete constraint. If you expect to often put one concrete Species into a collection, then it’s worth making a parameter for it:
julia> struct MassAction2{T<:Real,S<:Integer,R<:AbstractSpecies} <: AbstractRateLaw
           reactants::Union{Array{Tuple{R,T},2},Dict{R, T}}
           rate_constant::T
           T_dependence::S
       end

julia> MassAction2(params_vec_float,1.0,true)
MassAction2{Float64, Bool, Species}(Tuple{Species, Float64}[(Species("A"), 1.0) (Species("B"), 2.0)], 1.0, true)

Note that the type constructor call omitted the type parameters because the type definition implicitly made another method that determined the parameters from the inputs. This behavior is overridden if you define your own constructor methods. If you specify R as AbstractSpecies manually, you get an unequal substitute to MassAction:

julia> dump(MassAction2{Float64, Bool, AbstractSpecies})
MassAction2{Float64, Bool, AbstractSpecies} <: AbstractRateLaw
  reactants::Union{Dict{AbstractSpecies, Float64}, Matrix{Tuple{AbstractSpecies, Float64}}}
  rate_constant::Float64
  T_dependence::Bool

julia> dump(MassAction{Float64, Bool})
MassAction{Float64, Bool} <: AbstractRateLaw
  reactants::Union{Dict{AbstractSpecies, Float64}, Matrix{Tuple{AbstractSpecies, Float64}}}
  rate_constant::Float64
  T_dependence::Bool

julia> MassAction2{Float64, Bool, AbstractSpecies} == MassAction{Float64, Bool}
false

As you can see, the Union is still in there and its still an abstract type, but it’s small enough that the compiler can optimize methods in many cases. Still, optimization is always more feasible when type parameters can narrow down fields to concrete types, so it’s worth considering refactoring. Food for thought, you want to be able to pass matrices and dictionaries as inputs, but do you really need to store them in the composite instance directly, e.g. you intend to mutate the inputs and the composite instance should adjust accordingly? Or can you process the inputs to a consistent format, whether you allow mutation of the composite instance or not? Similar input vs storage consideration applies to the Union in Species.

PS Union{} is itself a special abstract type, the ultimate subtype with no instances, so I amended the title.

2 Likes