Parametric Types with Union

I have the following snippet:

abstract type AbstractReaction end
abstract type AbstractSpecies end
abstract type AbstractRateLaw end

struct Species <: AbstractSpecies
    name::Union{AbstractString,Symbol}
end

struct MassAction{T<:Real,S<:Integer} <: AbstractRateLaw
    reactants::Union{Array{Tuple{AbstractSpecies,T},2},Dict{AbstractSpecies, T}}
    rate_constant::T
    T_dependence::S
end

The idea here is that the user can define some reactants and then use those reactants to construct a mass-action rate law. I am trying to make the field MassAction.reactants general, so that the user can pass two different types to instantiate the struct. For example, let’s say I define some reactants and some parameters that should be use to construct the MassAction rate law:

a = Species("A")
b = Species("B")

params_vec_float = [(a,1.0) (b,2.0)]
params_vec_int = [(a,1) (b,2)]
params_dict_float = Dict(a=>1.0, b=>2.0)
params_dict_int = Dict(a=>1, b=>2)

rate_law1 = MassAction{Float64,Bool}(params_vec_float,1.0,true)

Which leads to the following error:

ERROR: MethodError: Cannot `convert` an object of type 
  Matrix{Tuple{Species, Float64}} to an object of type 
  Union{Dict{AbstractSpecies, Float64}, Matrix{Tuple{AbstractSpecies, Float64}}}
Closest candidates are:
  convert(::Type{T}, ::T) where T at Base.jl:61
Stacktrace:
 [1] MassAction{Float64, Bool}(reactants::Matrix{Tuple{Species, Float64}}, rate_constant::Float64, T_dependence::Bool)
   @ Main ~/dev/julia/ReactorSimulator.jl/src/kinetics.jl:11
 [2] top-level scope
   @ REPL[7]:1

Upon reading the documentation, I found out that two objects the equivalency I am trying to use does not work because of parametric type invariance

My question is: how can I modify the type definition of the field MassAction.reactants so that any of the params_vec_float, params_vec_int, params_dict_float, params_dict_int would work when trying to instantiate the struct MassAction?

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

Thank you very much for this concise explanation!