I’d like some feedback on a seemingly functional design involving recursion and a whole lot of ::Val{}
overloads. I think that my time in the Julia community has conditioned me to shy away from such dispatching and probable compiler inference issues but this sort-of seems to work.
Some quick context: I’m refactoring Latexify.jl in order to both enable features that were previously hard to implement and to make it way more extensible for users and package developers. The previous code used to latexify expressions by essentially recursively latexifying the leaves of the expression tree. Find the bottoms of the expression → turn into strings → go up one level → go again. One key part there was the conversion of the leaves to strings which was done in a function with just a whole bunch of if elseif elseif .... elseif
s. Like if +
then convert an operation to a string with join(args, " + ")
, if /
then do something else. You get the point - a massive conditional.
Now, for the potential replacement (or an MWE of it):
## Some utils that are handy here.
value(::Val{T}) where T = T
ValUnion(x) = Union{typeof.((Val.(x)))...}
## what to do when the recursion reaches the bottom
func(x) = string(x)
## if expression, keep going
func(ex::Expr) = func(ex.head, ex.args[1], ex.args[2:end])
## if symbols -> create Val{} for overloading.
func(head::Symbol, operation::Symbol, args) = func(Val{head}(), Val{operation}(), args)
### Specify how to latexify each operation - or groups thereof.
## Note that these recurse by calling `func` on `args`
func(::Val{:call}, op::Val{:-}, args) = "$(func(args[1])) - $(func(args[2]))"
func(::Val{:call}, ::Val{:/}, args) = "\\frac{$(func(args[1]))}{$(func(args[2]))}"
func(::Val{:call}, op::ValUnion(:+, :*), args) = join(func.(args), " $(value(op)) ")
### try it out
ex = :(a + b * c / (d + c))
func(ex)
## returns "a + \\frac{b * c}{d + c}" - yay!
In a massive conditional, it would be hard for anyone but me to add new behaviour, but here one can just do
func(::Val{:call}, ::Val{:textcolor}, args) = "\\textcolor{$(args[1])}{$(func(args[2]))}"
and boom! - you can color parts of your expression
julia> ex = :(a + textcolor(red, b * c) / (d + c))
julia> func(ex)
"a + \\frac{\\textcolor{red}{b * c}}{d + c}"
The ValUnion
function allows me to, for example, have a list of all trigonometric functions that should all be treated just about the same and to do that in a single overload.
My real prototype is a bit more involved and has more features, but here you can see the basic design pattern: define a beginning and an end to the recursion. Then allow Val{}
overloading for any step in between.
I’ll end up with maybe 100 different func
methods (I have to find a reasonable name for that one).
My initial benchmarking seems to say that performance is similar to the massive conditional I used before. I suspect that it’s the string operations that takes the most time, not method look-up, but I have not dug deep yet. It’s currently on the order of 100 microseconds - not a huge deal since latexify should never be a performance bottleneck.
So, the question is pretty much: Is this design pattern fine in my case and is it fine in general for allowing more extensible alternatives to big conditionals? Or, as I seem to have been conditioned to think, is it evil?