Macros vs. Functions with @eval(return )

For my use case (generating a struct of four FunctionWrappers from a parsed string) I can use the following two syntaxes interchangeably, and the resulting FunctionWrappers themselves benchmark to the same thing using BenchmarkTools.jl. (Note that the below is a minimum example.)

function afunk(args)
   ...
   @eval( return (some expression) )
end

or

macro amac(args)
   ...
   return quote (some expression) end
end

I was looking at the documentation and noticed this:

" A macro maps a tuple of arguments to a returned expression, and the resulting expression is compiled directly rather than requiring a runtime eval call."

So I benchmarked the creation of the functions using both approaches and the macro version takes a near-zero time relative to the function approach which makes sense given the above as function creation would be handled at compile time by literally inserting the code in-place.

From this I would infer that (probably) all of the time the macro is preferable. But particularly so if the function is generated many times where generation speed may be important.

Do others agree? I am still getting to grips with metaprogramming so I am curious to know others’ opinion on the above two methods. Perhaps there are other trade-offs that I am not aware of?

1 Like

Some general rules are:

  1. use macro only when a function cannot do
  2. avoid eval (it is bad style and also risky if you don’t know what you may end up evaluating)
2 Likes

Thanks those rules seem sensible. However here they perhaps seem inconclusive?

Rule 1 suggests I should use a function, but rule 2 suggests I shouldn’t use the function because I would have to use an @eval.

So In your case, a macro is better. A thing to watch when using macros is that, if anything changes that the generated code depends on (trickier to track than without meta-programming), it has to be regenerated. If macro expansion (code generation) is really slow, you may try refactoring/optimizing it.

1 Like

Macros are just a convenience to allow you to write some terse syntax that is then mapped to some other syntax that would be painful to write by hand. They have absolutely no extra “powers” and are thus never strictly needed.

Using eval is fine if you use it for what it is meant, which is dynamically evaluating code. This is most often used in global scope to eg define a bunch of functions in a loop that only differ slightly from each other.

7 Likes

This form of function calling eval at the end is almost certainly not doing what you want it to, especially if you have a return statement inside do the eval call. Eval is always global in Julia so you are calling return in global scope, which doesn’t make sense.

4 Likes

I certainly agree I don’t understand why it works (I can’t get my head around return in the global scope), and there may be unintended consequences that cause bugs in the future versions of our software, but thus far @eval( return … ) does seem to be doing what we need.

Indeed, my concern about potential unintended consequences is my main reason for asking this Q.

At the moment we have chosen that over the macro approach because we are writing software that should be very approachable for non-Julia programming biologists and didn’t want to introduce the macro syntax.

The use case is that we have a number of models users might want to fit to their data, but we want the flexibility to freeze particular model parameters and fit on only the ‘unfrozen’ params. So to do this we generate the model functions with the frozen parameters ‘hardwired’ in via our function that does @eval. It’s working well so far.

A slightly tangential but demonstrative MWE of our approach is the code below which generates a function that N (user defined) exponential terms summed up plus a constant.

Any feedback welcome.


function expgen(terms)
  unpacker = ""
  for i in 0:(2*terms)
    unpacker = string(unpacker, "a", i, ", ")
  end
  unpacker = string(unpacker, "= params")
  unpacker_expression = Meta.parse(unpacker)
  
  expsum = "a0"
  for i in 1:2:(2*terms)
    expsum = string(expsum, " + a", i, "*exp(-ti/a", i + 1, ")")
  end
  expsum_expression = Meta.parse(expsum)

  @eval return((t, params) -> ($unpacker_expression; [$expsum_expression for ti in t]))
end

macro expgen(terms)
  unpacker = ""
  for i in 0:(2*terms)
    unpacker = string(unpacker, "a", i, ", ")
  end
  unpacker = string(unpacker, "= params")
  unpacker_expression = Meta.parse(unpacker)
  
  expsum = "a0"
  for i in 1:2:(2*terms)
    expsum = string(expsum, " + a", i, "*exp(-ti/a", i + 1, ")")
  end
  expsum_expression = Meta.parse(expsum)

  return quote (t, params) -> ($unpacker_expression; [$expsum_expression for ti in t]) end
end

fgen = expgen(10)
mgen = @expgen 10

t = collect(0.0:0.1:10.0)
params = [1.0 for i in 1:21]

Where fgen(t, params) == mgen(t, params) evaluates to true.

Edit: I just noticed that removing the return still works @eval ((t, params) -> ($unpacker_expression; [$expsum_expression for ti in t])) as long it is the last line of the function. Also having return at the beginning of the line is fine (before the @eval).

I have never, in all of my years with Julia, encountered a situation in which it makes sense to represent Julia code as strings, and I don’t think this is such a situation either. Regardless of whether eval() is your tool of choice, building up Julia code through string manipulation and then trying to parse it is going to be brittle and unnecessarily difficult. Julia has such a nice way of representing Julia code as data structures that there should be no need to go through string concatenation.

Trying to generate code through string manipulation makes it trivially easy to accidentally introduce syntax errors or produce code that doesn’t parse at all (or parses into something wildly different from what you expected), e.g. when you misplace a single ) or ].

Instead, any Julia expressions you need to construct can be built up by actually generating Expr objects. Even easier, you can use :() to quote code and splice in the values you care about. So rather than trying to build the string `“exp(-ti / a1)”, you can do:

julia> i = 1
1

julia> var_name = Symbol("a$i")
:a1

julia> expr = :(exp(-ti / $var_name))
:(exp(-ti / a1))

The huge benefit is that the resulting expr is guaranteed to be parsed into an expression (by construction it cannot have mis-matched parentheses or anything like that), and you can use it to build up more complex expressions:

julia> expsum = :(a0)
:a0

julia> expsum = :($expsum + $expr)
:(a0 + exp(-ti / a1))

julia> expsum = :($expsum + $expr)
:((a0 + exp(-ti / a1)) + exp(-ti / a1))
5 Likes

As far as I can tell there’s no need for (explicit) code generation here at all:

f(t, a0, a...) = map(t) do x
    s = a0
    for i = 1:2:length(a)
        s += a[i]*exp(-x/a[i+1])
    end
    return s
end
julia> t = collect(range(1, 3, length=5));

julia> params = collect(1.0:7.0);

julia> expgen(3)(t, params)
5-element Array{Float64,1}:
 10.909253031960596
 10.019040684184475
  9.216978180659456
  8.493354280115472
  7.839639771905324

julia> f(t, params...)
5-element Array{Float64,1}:
 10.909253031960596
 10.019040684184475
  9.216978180659456
  8.493354280115472
  7.839639771905324

This function is automatically specialized on the number and type of arguments.

5 Likes