What are the dangers of eval in macros, to get the values of an enum symbol

I’m working on an implementation of the PBRT 4 raytracer in Julia. To avoid having containers of abstract types for primitives, materials, etc, some of the base object types are defined using a tag, instead of subclasses, for example

@enum Shape SPHERE CUBE
struct Primitive
    shape::Shape
    center::SVector{3, Float64}
    size::Float64 # radius or side-length
end

I want to be able to dispatch on the value of the tag. The simplest way I can see is doing this. All “sub-functions” return the same type, which is easily inferable.

intersect_sphere(p::Primitive, r::Ray) = ...
intersect_cube(p::Primitive, r::Ray) = ...
function intersect(p::Primitive, r::Ray)
    p.shape == SPHERE && return intersect_sphere(p, r)
    p.shape == CUBE && return intersect_cube(p, r)
end

Since this is something that I will use a lot, I tried writing a macro to automate this pattern. This also avoids forgetting one of the enum values in the dispatch. However, I have the issue that the macro needs to know about Shape, specifically its instances, to generate this code. This is my current implementation:

macro tagdispatch(enum, f, sig, tag=:tag)
    fname = String(f)
    argsymbs = [gensym(string(T)) for T in sig.args]

    headerexpr = Expr(:call, f, 
        [
            Expr(:(::), s, T) for (s, T) in zip(argsymbs, sig.args)
        ]...
    )

    blockexpr = Expr(:block,
        [
            :($(argsymbs[1]).$tag == $v && 
                return $(Symbol(fname * "_" * lowercase(string(v))))($(argsymbs...))) 
            for v = instances(eval(enum))
        ]...
    )

    esc(Expr(:function, 
        headerexpr,
        blockexpr
    ))
end

@tagdispatch Shape intersect (Primitive, Ray) shape

This works, but to get access to the enum from its symbol, I use eval(enum), which I know is generally bad in macros. Specifically, what are the dangers of doing this, considering that in my case the enum will always be defined before the macro is used.

If it is definitely something to avoid, is there another way to solve this, other than write the functions by hand (which I could do, if necessary)?

Looks like you’re in the process of re-creating SumTypes.jl

using SumTypes, StaticArrays
@sum_type Primitive begin
    Sphere(center::SVector{3, Float64}, radius::Float64)
    Cube(center::SVector{3, Float64}, side_length::Float64)
end

function intersect(p::Primitive, r::Ray)
    @cases p begin
        Sphere(center, radius) => ...
        Cube(center, side_length) => ...
    end
end

The (perhaps confusingly named) package DynamicSumTypes.jl might also be an even better fit here, I think you’d just write

using DynamicSumTypes

struct Sphere # regular julia type!
    center::SVector{3, Float64}
    radius::Float64
end
struct Cube # regular julia type!
    enter::SVector{3, Float64}
    radius::Float64
end

# sum-type that can contain either a Sphere or a Cube
@sumtype Primitive(Sphere, Cube)

# if you get a Primitive, unpack it and dispatch on the result
intersect(p::Primitive, r::Ray) = intersect(variant(p), r) 

# regular dispatch!
intersect(s::Sphere, r::Ray) = ...
intersect(s::Cube, r::Ray)   = ... 

Btw, I’m putting quite a bit of time into making Trace.jl perform well and run on the GPU:

Which is also based on PBRT.

I solved some of those problems, by also creating my own “uber” types basically.
Maybe it will be nice to switch to DynamicSumTypes at some point.

I’m still looking into how to efficiently iterate over different primitive types, but simply converting everything to a triangle mesh works pretty well for now.

3 Likes

Just to answer your original question, one bug (albeit fixable) is that eval(expr) in a macro evaluates in the module the macro was defined, which is probably not what you wanted.

The core philosophical reason to avoid this is that you generally want to delay evaluation until the code is actually run — and thus macros should just do what they need to in order to spit out the expressions that’ll run later. Muddling what get executed when is just a cause for confusion in many cases.

This case, though, is more comparable to a replacement for a metaprogramming loop like:

for v in instances(Shape)
    fname = Symbol(:intersect_, v)
    @eval $fname(arg) = ...
end

Which, yeah, uses @eval and is perhaps the most idiomatic way to ever use eval. Even if you’re doing this with lots of enums, you can just add another outer for loop. It’s a pretty common pattern that most folks will immediately identify and understand — and Revise.jl can understand it, too! I don’t think the macro-generated definitions are typically Revise-able.

I suppose if you really wanted to use a macro here you could — and then one step better would be to getfield(__module__, enum) to resolve the name in the correct scope instead doing a whole eval.