Macro to create a function with a variable number of elseif branches

Hello,

I am starting to try and use/understand meta-programming and wanted to create a macro to overload the getproperty function for custom types that are custom implementation of the Point in GeometryBasics (subtypes of StaticVectors) , and can have either 2 or 3 dimensions.

I wanted to create a macro @custom_getproperty that allowed to assign custom property names to this types so that for example I type:
@custom_getproperty MyType (:x,:y), the macro would generate a custom getproperty implementation that access x and y as the first and second element of the :data field of the custom type (:data is the only field of the type).

To try and have the capability of creating 2 or 3 elseif branches depending on the number of the propertynames provided as a tuple, I ended up creating this macro code.

 macro custom_getproperty(name,symbolstuple)
           a = "function Base.getproperty(obj::$(name),s::Symbol)
           if s == :data
               getfield(obj,:data)
           "
           for (i,sym) in enumerate(eval(symbolstuple))
               a *= "elseif s == $(Meta.quot(sym))
                   getfield(obj,:data)[$i]
               "
           end
           a *=
           "else
               error(\"The $(name) type does not have a \$(String(s)) property\")
           end
           end"

           esc(Meta.parse(a))
  end

While this appears to work in achieving the functionality I wanted, I do feel this is somehow a bad/evil way of dealing with this problem.
Is there a better/cleaner way of achieving this functionality in a macro?

Don’t use string and everything else is fine with this approach. Also elseifs are just nested ifs

2 Likes

By don’t use string you mean the last string inside the error, or don’t use strings at all in the macro?
As I wouldn’t know how to make this macro without using strings followed by a Meta.parse.
Could you give me an example In case?

Sorry if this is very basic but I am very new to metaprogramming

https://docs.julialang.org/en/v1/manual/metaprogramming/#man-macros has some good info. You can see that strings are not used.

2 Likes

Thanks,
I actually have read the metaprogrammin section of the manual multiple times, and it went from completely obscure at the beginning of my julia journey to always a bit more understandable.

While I did see that there are no string in the example macros of the manual, I can not think of another way of generating a code whose inner part has a variable number of elseif statements that depends on the value of one of the macro arguments.
If for example I knew that the number of properties in my example was just 2, I know I could make the macro without strings as this:

macro custom_getproperty2(name,symbolstuple)
    esc(
        quote
            function Base.getproperty(obj::$(name),s::Symbol)
                if s == :data
                    getfield(obj,:data)
                elseif s == $(symbolstuple)[1]
                    getfield(obj,:data)[1]
                elseif s == $(symbolstuple)[2]
                    getfield(obj,:data)[2]
                else
                    error("The $(name) type does not have a $(String(s)) property")
                end
            end
        end 
    )
  end

At the same time, if I had certainly 3 elements I could do basically the same by adding by hand another elseif in the quote block.
The problem if that I do not know how to generate such code programmatically depending on the number of the elements in the tuple argument (outside of my code at the beginning using strings).
My initial idea was to generate the the first if part, then a variable number of elseif expressions, and then the last part and concatenate all the expressions together.
The problem is that I don’t know how to concatenate expressions together.
I tried creating an expression with exp = :(if cond statement end) and appending another if expression as last element like exp.args[3] = :(if cond2 statement2 end) (trying to interpret the statement from @yuyichao that elseif are just nested ifs) but I get an error.

That was why I would be very grateful if someone could give me an example to replicate my code in the original post but without the strings, so that I could try to reverse engineer the code to try understanding :).

You can figure out how to generate the code you want simply by dumping or Meta.show_sexpr the expression.

2 Likes

I see, maybe you can use this example for something:

macro n_nested_ifs(x, n)  
    orig_expr = Expr(:if, :($(esc(x)) == 1), :(print(1)))
    expr = orig_expr
    for i in 1:n-1
        push!(expr.args, Expr(:elseif, :($(esc(x)) == $i), :(print($i))))
        expr = expr.args[end]
    end
    push!(expr.args, :(error("failed to match")))
    return orig_expr
end

@macroexpand @n_nested_ifs(a, 5)
3 Likes

@kristoffer.carlsson beat me to it. Is there any practical difference between using elseif vs a series of &&?

macro custom_getproperty(name, symbolstuple...)
    methoddef = :(function Base.getproperty(obj::$name, s::Symbol) end)
    functionblock = last(methoddef.args).args

    for (i, sym) in enumerate(symbolstuple)
        if_case = :(s === $(symbolstuple[i]) && return getfield(obj, :data)[$i])
        push!(functionblock, if_case)
    end

    push!(functionblock, :(error("type $($name) has no field $s")))

    esc(methoddef)
end
2 Likes

Thanks a lot both to @kristoffer.carlsson and @tomerarnon, both these alternative approaches are insightful

There is actually a strange thing happening with the interpolation in the last error string.

If I try to do a @macroexpand this is what I get

@macroexpand @custom_getproperty Point2 :x :y
:(function Base.getproperty(obj::Point2, s::Symbol)
      #= Untitled-1:41 =#
      #= Untitled-1:41 =#
      s === :x && return (getfield(obj, :data))[1]
      s === :y && return (getfield(obj, :data))[2]
      error("type $(Point2) has no field $(s)")
  end)

As you see the string for the error does not directly put Point2 inside but try to interpolate the value of the variable Point2.

By looking at the dump of the correct error expression and trying to use the code from @kristoffer.carlsson I came up with this substitution for the last line
push!(functionblock, Expr(:call,:error,Expr(:string,"Type $(name) does not have property ",:s)))
This works but is there any easier way of achieving the correct string interpolation inside the error?

maybe something like this:

push!(functionblock, :(error("type ", $(string(name)), " has no field $s")))

or this (if you manage wrapping your head around the nested interpolation):

push!(functionblock, :(error("type $($(string(name))) has no field $s")))
2 Likes