Metaprogramming in Julia is a big topic and it’s covered extensively in both the official documentation as well as in the Introducing Julia wikibook. The idea behind metaprogramming is to write code which itself will either generate or change other code. Two main features of the language support this idea:
- code representation (expressions and symbols) and
- macros.
Code Representation
A symbol (data type Symbol
) represents an unevaluated chunk of code. As such, symbols are a means to refer to a variable (or expression) itself rather than the value it contains.
n = 5 # Assign to variable n.
5
n # Refer to contents of variable n.
5
typeof(n)
Int64
:n # Refer to variable n itself using quote operator.
:n
typeof(:n)
Symbol
eval(:n)
5
E = :(2x + y) # Unevaluated expression is also a symbol.
:(2x + y)
typeof(E)
Expr
The quote operator, :
, prevents the evaluation of its argument.
Expressions are made up of three parts: the operation (head
), the arguments to that operation (args
) and finally the return type from the expression (typ
).
names(E)
3-element Array{Symbol,1}:
:head
:args
:typ
E.head
:call
E.args
3-element Array{Any,1}:
:+
:(2x)
:y
E.typ
Any
We can evaluate an expression using eval()
. Not only does eval()
return the result of the evaluated expression but it also applies any side effects from the expression (for example, variable assignment).
x = 3; y = 5; eval(E)
11
eval(:(x = 4))
4
eval(E)
13
No real surprises there. But the true potential of all this lies in the fact that the code itself has an internal representation which can be manipulated. For example, we could change the arguments of the expression created above.
E.args[3] = :(3y) # 2x + y becomes 2x + 3y
:(3y)
E
:(2x + 3y)
eval(E)
21
That still seems a little tame. What about manipulating a function?
F = :(x -> x^2)
:(x->begin # none, line 1:
x^2
end)
eval(F)(2) # Evaluate x -> x^2 for x = 2
4
F.args[2].args[2].args[3] = 3 # Change function to x -> x^3
3
eval(F)(2) # Evaluate x -> x^3 for x = 2
8
Macros
Macros are a little like functions in that they accept arguments and return a result. However they are different because they are evaluated at parse time and return an unevaluated expression.
macro square(x)
:($x * $x)
end
@square(5)
25
@square 5
25
macroexpand(:(@square(x)))
:(x * x)
macroexpand(:(@square(5)))
:(5 * 5)
macroexpand(:(@square(x+2)))
:((x + 2) * (x + 2))
macroexpand()
is used to look at the code generated by the macro. Note that parentheses were automatically inserted to ensure the correct order of operations.
Julia has a plethora of predefined macros which do things like return the execution time for an expression (@time
), apply an assertion (@assert
), test approximate equality (@test_approx_eq
) and execute code only in a UNIX environment (@unix_only
).
The fact that one can use code to build and edit other code made me start thinking about self-replicating machines, self-reconfiguring modular robots, grey goo and utility fog. If we can do it in software, why not in hardware too? More evidence of my tinkering with metaprogramming in Julia can be found on GitHub. No self-reconfiguring modular robots though, I’m afraid.