Macro to crate a struct and its external constructor

Hi there. I’m new to metaprogramming and I’m running into trouble when trying to create a macro for replacing some bolier plate code. The code creates a series of structs and their corresponding external constructors.
One example for the struct and the constructor is below:

struct FnaP <: FastaPaths
    p::String
    ext::String
end

function FnaP(p::String)::FnaP
    ext = getFileExtention(p)
    if ispresent(ext, CLM_General.ALLOWED_EXT["FnaP"])
        return FnaP(p, ext)
    else    
        error("The file '$p' is not a valid fasta nucleic acid file.")
    end
end

My code above creates three methods for the FnaP constructor, which accept different types of inputs:

FnaP(::String)
FnaP(::String, ::String)
FnaP(::Any, ::Any)

Because I need to declare maye similar structs and corresponding constructors, I’ve created a macro as follows:

macro definePathDT(name::Symbol)#, allowed_ext::String)
    quote
        struct $name <: PathsDT
            p::String
            ext::String
        end

        function $name(p::String)
            ext = getFileExtention(p)
            if ispresent(ext, ALLOWED_EXT[$(string(name))]) #CLM_General.
                return $name(p, ext)
            else    
                error("The file '\$p' is not a valid $(string(name)) file.")
            end
        end
    end
end

When I call the macro with

@definePathDT FnaP

I only get two methods for my FnaP constructor, both corresponding to the inner constructor:

FnaP(::String, ::String)
FnaP(::Any, ::Any)

Basically, I don’t get the external constructor.

What am I doing wrong?

@macroexpand is very helpful in debugging macro code. Invoking your macro yields

julia> @macroexpand @definePathDT FnaP
quote
    #= REPL[1]:3 =#
    struct FnaP <: Main.PathsDT
        #= REPL[1]:4 =#
        p::Main.String
        #= REPL[1]:5 =#
        ext::Main.String
    end
    #= REPL[1]:8 =#
    function var"#41#FnaP"(var"#43#p"::Main.String)
        #= REPL[1]:8 =#
        #= REPL[1]:9 =#
        var"#42#ext" = Main.getFileExtention(var"#43#p")
        #= REPL[1]:10 =#
        if Main.ispresent(var"#42#ext", Main.ALLOWED_EXT["FnaP"])
            #= REPL[1]:11 =#
            return var"#41#FnaP"(var"#43#p", var"#42#ext")
        else
            #= REPL[1]:13 =#
            Main.error("The file '\$p' is not a valid $(Main.string(Main.name)) file.")
        end
    end
end

Look at the mangled variable names. The macro hygiene pass has converted all function and variable names into generic ones to avoid clashing with existing ones, e.g. a function $name might already exist in the evaluating module.
This shouldn’t pose a problem here.

To prevent macro hygiene, enclose the returned expression with esc(...).

macro definePathDT(name::Symbol)#, allowed_ext::String)
    esc(quote
        struct $name <: PathsDT
            p::String
            ext::String
        end

        function $name(p::String)
            ext = getFileExtention(p)
            if ispresent(ext, ALLOWED_EXT[$(string(name))]) #CLM_General.
                return $name(p, ext)
            else    
                error("The file '\$p' is not a valid $(string(name)) file.")
            end
        end
    end)
end
julia> abstract type PathsDT end

julia> @definePathDT FnaP
FnaP

julia> methods(FnaP)
# 3 methods for type constructor:
 [1] FnaP(p::String)
     @ REPL[6]:8
 [2] FnaP(p::String, ext::String)
     @ REPL[6]:4
 [3] FnaP(p, ext)
     @ REPL[6]:4
3 Likes

This is what the escaped expression looks like. The other effect of escaping besides changing some variable names (the docs call them “local” to the expression but the criteria isn’t very clear there) is that function calls are not taken from the macro definition’s scope anymore; the Main. are gone so the symbols will instead take from the macro call scope. This is as close to pasting code as it gets, and if some symbols are intended to belong to the macro definition scope, it’s just a matter of writing multiple esc at the other subexpressions (there is no unescaping).

julia> @macroexpand @definePathDT FnaP # the esc version
quote
    #= REPL[16]:3 =#
    struct FnaP <: PathsDT
        #= REPL[16]:4 =#
        p::String
        #= REPL[16]:5 =#
        ext::String
    end
    #= REPL[16]:8 =#
    function FnaP(p::String)
        #= REPL[16]:8 =#
        #= REPL[16]:9 =#
        ext = getFileExtention(p)
        #= REPL[16]:10 =#
        if ispresent(ext, ALLOWED_EXT["FnaP"])
            #= REPL[16]:11 =#
            return FnaP(p, ext)
        else
            #= REPL[16]:13 =#
            error("The file '\$p' is not a valid $(string(name)) file.")
        end
    end
end
1 Like

Thanks @skleinbo and @Benny. I’ve used the esc() and now it works as intended. I have a further question now.
How can I write a for loop to use the @definePathDT to generate multiple Data Types.

Basically, I can write:

@definePathDT FnaP
@definePathDT FaaP
@definePathDT FastaQP
@definePathDT BamP
@definePathDT TableP

However, instead, I would like to use a for loop. But anytime I’m doing something like:

mynames = [:Fna, :Faa]
for i in 1:eachindex(mynames)
  @definePathDT mynames[i]
end

I get an error:

LoadError: MethodError: no method matching var"@definePathDT"(::LineNumberNode, ::Module, ::Expr)

Closest candidates are:
  var"@definePathDT"(::LineNumberNode, ::Module, ::Symbol)

Any idea?
Thanks

Macros are not like function calls that happen at runtime, they transform the code as Julia reads it, before it’s actually run. So what @definePathDT mynames[i] is working on isn’t Fna or Faa, it’s the expression mynames[i]. That error means that your definePathDT(::Symbol) macro method can’t work on ::Expressions, but even if you evade that error, mynames[i] makes no sense as an input e.g. struct mynames[i] <: PathsDT.

So, we need to construct an expression with a macro call at runtime and use a function call to evaluate it at runtime.

for i in eachindex(mynames) # fixed eachindex typo
  eval( :( @definePathDT $(mynames[i]) ) )
end

Funnily enough there’s a macro to save writing out the quote delimiters, but it just expands to the runtime function call above. Bear in mind that @eval was designed to handle $ as if it were quote interpolation, $ is not generally something that works in macro expressions.

for i in eachindex(mynames) # fixed eachindex typo
  @eval @definePathDT $(mynames[i])
end

I’m sure you can also put together that you didn’t need a macro at all, you could’ve evaled the big quote in a loop, where you won’t have to consider macro hygiene and variable mangling. This pattern is explained in the section Code Generation of the Metaprogramming page of the Manual.

3 Likes

@Benny yes you are right, I don’t need to create a macro for generating boiler plate code. It seems like I was using a very round about way to solve problem.
As you suggested, I used directly eval() function and my code looks like this now:

for sym in [:FnaP, :FaaP, :FastaQP, :BamP, :TableP]
    eval(quote
        struct $sym <: PathsDT
            p::String
            ext::String
        end

        function $sym(p::String)
            ext = getFileExtention(p)
            if ispresent(ext, ALLOWED_EXT[$(string(sym))]) #CLM_General.
                return $sym(p, ext)
            else  
                error("The file '\$p' with extention '\$ext' is not a valid $(string($sym)) file.")
            end
        end
    end)
end

It works quite fine! The newly generated data types and constructors can be exported and used from other modules, like regular code.

Thank you both for helping me wrap up my head around metaprograming.