If op_index is never computed but is constant, you could use Vals, e.g., replace
with
function run_op(calculator::C, i::Val{op_index}, value) where {C<:Calculator,op_index}
and then call it like run_op(c, Val(1), 3.5). With that change (to both functions), the benchmarks are very similar. Once you have to iterate over each element of calculator.ops, though, it seems not to work so well.
If your goal is to chain the computations (i.e., something like op(ops[2], op(ops[1], value))), then you could use reduce:
function run_reduce(calculator::Calculator, value)
reduce(calculator.ops; init = Add(value)) do o1, o2
Add(op(o2, o1.foo))
end.foo
end
(Admittedly, this function looks a bit strange due to somewhat randomly having to wrap all the values in an Op. With some refactoring of your structs it could probably be made to look better.)
I’ve never worked with @generated functions before, so I took this opportunity to try it out. Here’s what I came up with:
@generated function run_op_manual_generated(calculator::Calculator{<:NTuple{N,Op}}, op_index, value) where {N}
N < 1 && return :(error("boink"))
ex = Expr(:if, :(op_index == 1), :(return op(calculator.ops[1], value)))
args = ex.args
for i = 2:N
push!(args, Expr(:elseif, :(op_index == $i), :(return op(calculator.ops[$i], value))))
args = args[3].args
end
push!(args, :(error("boink")))
return ex
end
This seems to run just as fast as run_op_manual and is type stable (even for larger Calculators; I tried 26 operations, maybe it breaks if you go higher).
Hopefully something I’ve said is helpful.
By the way,
is probably not doing what you think it is. As written, the where {O<:Tuple{Op}} actually does nothing, because there is no type parameter O in AbstractCalculator. (The O in Calculator{O} is not the same O). You probably meant
struct Calculator{O<:NTuple{N,Op} where N} <: AbstractCalculator
(The NTuple{N,Op} is necessary to allow an arbitrary number of Ops; Tuple{Op} allows just one, i.e., Tuple{Op} == NTuple{1,Op}.)