Dispatch function based on value of parametric type


#1

I have been thinking about parametric types lately and how to use them so I came up with a thought experiment that I can’t seem to figure out, but I am pretty sure is possible. For this experiment lets assume there is a boolean associated with a type. There are two method routes for this type one to be taken one if the boolean is true, and one to be taken if it is false.

I could see this as an alternative to a situation where you have a base type and then two subtypes one that signals a condition is true and one that signals an alternate condition. And where the only difference between the subtypes is the presence or absence of this condition. In essence I want to implement this functionality only by storing the boolean as a parametric and not a field. This code shows the non-parametric way of doing this:

struct WorkingType{T}
    B::Bool
    s::T
end

w = WorkingType(true, 10) # true condition
w2 = WorkingType(false, 10) # false condition
testval(w) = w.B ? print(w.s) : print(-1 * w.s)

testval(w) # 10
testval(w2) # -10

Now lets assume i still want to store s in a field but have its type as a parametric so it can support a variety of types and I just want Julia to dispatch to the correct function based on the (Boolean) parametric being true or false. Here is an outline of what I think needs to happen with ??? where I am unsure of the syntax.

struct BustedType{B, T}
    s::T
end

t = BustedType{???}(???) # true condition
t2 = BustedType{???}(???) # false condition

testval(::BustedType{???}) = print(???)  # 10
testval(::BustedType{???}) = print(-1 * ???) # -10

Bonus question: Would it be possible to do the above except store both S and B as parametric values and then switch on three conditions ex:

  • s < 10 && b is true = print 1
  • s >= 10 && b is true = print 1 * s
  • s is false = print -1 * s

#2
julia> struct BustedType{B, T}
           s::T
       end

julia> BustedType{B}(s::T) where {B,T} = BustedType{B,T}(s)

julia> t = BustedType{true}(10)
BustedType{true,Int64}(10)

julia> t2 = BustedType{false}(10)
BustedType{false,Int64}(10)

julia> testval(w::BustedType{true}) = print(w.s)
testval (generic function with 1 method)

julia> testval(w::BustedType{false}) = print(-1 * w.s)
testval (generic function with 2 methods)

julia> testval(t)
10
julia> testval(t2)
-10
julia> @which testval(t)
testval(w::BustedType{true,T} where T) in Main at REPL[5]:1

julia> @which testval(t2)
testval(w::BustedType{false,T} where T) in Main at REPL[6]:1

julia> struct BustedType2{B, s} end

julia> t = BustedType2{true,10}()
BustedType2{true,10}()

julia> t2 = BustedType2{false,10}()
BustedType2{false,10}()

julia> @generated function testval(::BustedType2{B,s}) where {B, s}
           if s < 10 && B
               return :(print(1))
           elseif s >= 10 && B
               return :(print(s))
           else
               return :(print(-s))
           end
       end
testval (generic function with 3 methods)

julia> testval(t)
10
julia> testval(t2)
-10

Note that when you make some field a type parameter, you can no longer enforce its type to be a subtype of Integer for example so nothing stops you from making a BustedType2{"A", "B"} which will error if comparing "B" < 10.


#3

Just put T <: Integer in the type parameter list?


#4

No, because it seems the OP wants true and 10 to be in the parameters, so these are not types anymore and <: Integer only works if the parameter is a type, not an object with type <: Integer.


#5

Thanks, I had misunderstood.


#6

The solution is much simper than I thought thanks! The documentation I was looking at must have been old. It had examples with Val{} in it which just seemed…weird.

One question. When you define the method testval(w::BustedType{true}) = print(w.s) you only use w::BustedType{true} even though the actual type is BustedType{true, T} as is seen on the @which lines. Can the unimportant (for dispatch) parameters typically be discarded? Or was this just a unique case?


#7

You could validate in an inner constructor:

struct BustedType2{B,S}
    function BustedType2{B,S}() where {B,S}
        (isa(B,Bool) && isa(S,Integer)) || error("bad params")
        new()
    end
end

#8

Well, BustedType{true} is an alias for BustedType{true, T} where T. In one layered parameterization, you can drop the unused parameters from the end without a problem. But you cannot drop the first parameter only for example without aliasing the type. For example, you can do const InvertedBustedType{T,B} = BustedType{B,T} then you can drop B if you want using InvertedBustedType{T} which is now an alias of BustedType{B,T} where B.

For deeper parameterizations, it’s best to use all parameters (unless you are dealing with tuples or dropping parameters at the shallowest level). A rule of thumb is to check that every possible valid argument arg passes the arg isa T test, where the function signature is f(arg::T). One problem you may run into when omitting parameters is this:

julia> a = [1,2,3];

julia> a isa Array{Int}
true

julia> a isa Array{Int, 1}
true

julia> a = [[1,2,3], [1,2,3], [1,2,3]];

julia> a isa Array{Array{Int}, 1}
false

julia> a isa Array{Array{Int, 1}, 1}
true

julia> a[1] isa Array{Int}
true

Omitting the parameter from the inner Array{Int, 1} led to an undesired behavior because a does not pass the arg isa T test anymore, even though a[1] isa Array{Int} is true. This is the infamous type invariance issue of Julia types, see https://docs.julialang.org/en/stable/manual/types/#Parametric-Composite-Types-1. Tuples are an exception in that isa can “see through” a tuple, so a tuple layer doesn’t cause the same problem an array or any other layer would.

julia> a = ([1,2,3], [1,2,3], [1,2,3]);

julia> a isa NTuple{3, Array{Int}}
true

julia> a isa NTuple{3, Array{Int, 1}}
true

#9

One last question on the subject that I don’t see in the parametric types docs.

Is there an easy way to manipulate the parameters stored in a type? For example, turn BustedType2{true,10}() into BustedType2{false,10}() by just flipping the true to a false? I would like to do this without having to re-create a whole new type.


#10

Why are trying to do this using type parameters, instead of just making a new type with these variables inside?


#11

Manipulating is not possible as far as I know. Making a new instance with the Boolean flipped, that’s possible but if you assign it to the same variable, that’s type instability, because you would have changed the type of the variable. Making a new instance and assigning it to a different variable is the healthiest option:

julia> struct BustedType2{B,S} end

julia> flipped(::BustedType2{B,S}) where {B,S} = BustedType2{!B,S}()
flipped (generic function with 1 method)

julia> t = BustedType2{true,10}()
BustedType2{true,10}()

julia> t2 = flipped(t)
BustedType2{false,10}()

And resonating David’s question, it’s unlikely you really need this. If you need to change B often, then make it a field in a mutable struct.


#12

Nothing in particular. It is a thought / simple code experiment to understand how parameterized types work and their limitations.