Since the beginning of Julia, it has been tempting to use macros to write domain-specific languages (DSLs), i.e. to extend Julia syntax to provide a simpler interface to create Julia objects with complicated behaviour. The first, and still most extensive, example is JuMP.
Since the fix for the infamous early Julia issue #265, which was incorporated in Julia 0.6, some previous methods for creating DSLs in Julia, mainly involving eval
, ceased to work.
In this post, we will describe a recommended pattern (i.e., a reusable structure) for creating DSLs without the use of eval
, using syntax suitable for Julia 0.6 and later versions; it is strongly recommended to upgrade to Julia 0.6.
Model
object containing a functionThis blog post arose from a question in the JuliaCon 2017 hackathon about the Modia modelling language, where there is a @model
macro. Here we will describe the simplest possible version of such a macro, which will create a Model
object that contains a function, and is itself callable.
First we define the Model
object. It is tempting to write it like this:
struct NaiveModel
f::Function
end
We can then create an instance of the NaiveModel
type (i.e., an object of that type) using the default constructor, e.g. by passing it an anonymous function:
julia> m1 = NaiveModel(x -> 2x)
NaiveModel(#1)
and we can call the function using
julia> m1.f(10)
20
If we wish instances like m
to themselves behave like functions, we can overload the call syntax on the NaiveModel
object:
julia> (m::NaiveModel)(x) = m.f(x)
so that we can now just write
julia> m1(10)
20
Since Function
is an abstract type, for performance we should not have a field of this type inside our object. Rather, we parametrise the type using the type of the function:
struct Model{F}
f::F
end
(m::Model)(x) = m.f(x)
julia> m2 = Model(x->2x)
Model{##3#4}(#3)
julia> m2(10)
20
Let's compare the performance:
julia> using BenchmarkTools
julia> @btime m1(10);
41.482 ns (0 allocations: 0 bytes)
julia> @btime m2(10);
20.212 ns (0 allocations: 0 bytes)
Indeed we have removed some overhead in the second case.
We wish to define a macro that will allow us to use a simple syntax, of our choosing, to create objects. Suppose we would like to use the syntax
julia> @model 2x
to define a Model
object containing the function x -> 2x
. Note that 2x
on its own is not valid Julia syntax for creating a function; the macro will allow us to use this simplified syntax for our own purposes.
Before getting to macros, let's first build some tools to manipulate the expression 2x
in the correct way to build a Model
object from it, using standard Julia functions.
First, let's create a function to manipulate our expression:
function make_function(ex::Expr)
return :(x -> $ex)
end
julia> ex = :(2x);
julia> make_function(ex)
:(x->begin # In[12], line 2:
2x
end)
Here, we have created a Julia expression called ex
, which just contains the expression 2x
that we would like for the body of our new function, and we have passed this expression into make_function
, which wraps it into a complete anonymous function. This assumes that ex
is an expression containing the variable x
and makes a new expression representing an anonymous function with the single argument x
. (See e.g. my JuliaCon 2017 tutorial for an example of how to walk through the expression tree in order to extract automatically the variables that it contains.)
Now let's define a function make_model
that takes a function, wraps it, and passes it into a Model
object:
function make_model(ex::Expr)
return :(Model($ex))
end
julia> make_model(make_function(:(2x)))
:(Model((x->begin # In[12], line 2:
2x
end)))
If we evaluate this "by hand", we see that it correctly creates a Model
object:
julia> m3 = eval(make_model(make_function(:(2x))))
Model{##7#8}(#7)
julia> m3(10)
20
However, this is ugly and clumsy. Instead, we now wrap everything inside a macro. A macro is a code manipulator: it eats code, massages it in some way (possibly including completely rewriting it), and spits out the new code that was produced. This makes macros an incredibly powerful (and, therefore, dangerous) tool when correctly used.
In the simplest case, a macro takes as argument a single Julia Expr
object, i.e. an unevaluated Julia expression (i.e., a piece of Julia code). It manipulates this expression object to create a new expression object, which it then returns.
The key point is that this returned expression is "spliced into" the newly-generated code in place of the old code. The compiler will never actually see the old code, only the new code.
Let's start with the simplest possible macro:
macro model(ex)
@show ex
@show typeof(ex)
return nothing
end
This just shows the argument that it was passed and exits, returning an empty expression.
julia> m4 = @model 2x
ex = :(2x)
typeof(ex) = Expr
We see that the Julia Expr
object has been automatically created from the explicit code that we typed.
Now we can plug in our previous functions to complete the macro's functionality:
julia> macro model(ex)
return make_model(make_function(ex))
end
@model (macro with 1 method)
julia> m5 = @model 2x
Model{##7#8}(#7)
julia> m5(10)
20
To check that the macro is doing what we think it is, we can use the @macroexpand
command, which itself is a macro (as denoted by the initial @
):
julia> @macroexpand @model 2x
:((Main.Model)((#71#x->begin # In[12], line 2:
2#71#x
end)))
However, our macro has an issue, called macro "hygiene". This has to do with where variables are defined. Let's put everything we have so far inside a module:
module Models
export Model, @model
struct Model{F}
f::F
end
(m::Model)(x) = m.f(x)
function make_function(ex::Expr)
return :(x -> $ex)
end
function make_model(ex::Expr)
return :(Model($ex))
end
macro model(ex)
return make_model(make_function(ex))
end
end
Now we import the module and use the macro:
julia> using Models
julia> m6 = @model 2x;
julia> m6(10)
20
So far so good. But now let's try to include a global variable in the expression:
julia> a = 2;
julia> m7 = @model 2*a*x
Models.Model{##7#8}(#7)
julia> m7(10)
UndefVarError: a not defined
Stacktrace:
[1] #7 at ./In[1]:12 [inlined]
[2] (::Models.Model{##7#8})(::Int64) at ./In[1]:9
We see that it cannot find a
. Let's see what the macro is doing:
julia> @macroexpand @model 2*a*x
:((Models.Model)((#4#x->begin # In[1], line 12:
2 * Models.a * #4#x
end)))
We see that Julia is looking for Models.a
, i.e. a variable a
defined inside the Models
module.
To fix this problem, we must write an "unhygienic" macro, by "escaping" the code, using the esc
function. This is a mechanism telling the compiler to look for variable definitions in the scope from which the macro is called (here, the current module Main
), rather than the scope where the macro is defined (here, the Models
module):
module Models2
export Model, @model
struct Model{F}
f::F
end
(m::Model)(x) = m.f(x)
function make_function(ex::Expr)
return :(x -> $ex)
end
function make_model(ex::Expr)
return :(Model($ex))
end
macro model(ex)
return make_model(make_function(esc(ex)))
end
end
julia> using Models2
julia> a = 2;
julia> m8 = @model 2*a*x
Models2.Model{##3#4}(#3)
julia> m8(10)
40
This is the final, working version of the macro.
We have successfully completed our task: we have seen how to create a macro that enables a simple syntax for creating a Julia object that we can use later.
For some more in-depth discussion of metaprogramming techniques and macros, see my video tutorial Invitation to intermediate Julia, given at JuliaCon 2016:
link to the video
link to the Jupyter notebooks
Author: David P. Sanders, Associate Professor, Department of Physics, Faculty of Sciences, National University of Mexico (UNAM).