Julia Metaprogramming

Julia represents its own code as data structures in the language, so that we can write programs that manipulate programs.

Metaprogramming can also be simply understood as writing code that can generate code.

Metaprogramming refers to the writing of a certain class of computer programs that write or manipulate other programs (or themselves) as their data, or that accomplish at compile time part of the work that should have been done at runtime. In most cases, compared with writing all code by hand, programmers can achieve higher work efficiency, or give the program greater flexibility to handle new situations without recompiling.

The language used to write metaprograms is called the metalanguage. The language of the program being manipulated is called the "target language". The ability of a programming language to also be its own metalanguage is called "reflection" or "reflexivity".

-- Wikipedia

Julia Source Code Execution Stages

1. Parse the original Julia code: The Julia parser first parses the string to obtain an abstract syntax tree (AST), which is a structure that contains all the code in an easy-to-manipulate format.

2. Execute the parsed Julia code:: In this stage, the parsed Julia code is executed.

When we enter code in the interactive programming environment (REPL) and press Enter, the above two stages are executed.

With metaprogramming tools, we can access the Julia code between these two stages, that is, after the source code is parsed but before it is executed.

Program Representation

Julia provides a Meta module, in which Meta.parse(str) can be used to parse a string, and typeof(e1) returns Expr:

Example

julia> prog = "1 + 1"
"1 + 1"
julia> ex1 = Meta.parse(prog)
:(1 + 1)

julia> typeof(ex1)
Expr

Returns:(1 + 1)The returned value consists of a colon and the following expression; typeof(ex1) returns Expr.

The Expr object contains two parts (ex1 contains the head and args attributes):

One is a symbol object that identifies the type of the expression.

Example

julia> ex1.head
:call

The other is the arguments of the expression, which may be symbols, other expressions, or literals:

Example

julia> ex1.args
3-element Vector{Any}:
  :+
 1
 1

Expressions can also be constructed directly with Expr:

Example

julia> ex2 = Expr(:call, :+, 1, 1)
:(1 + 1)

The two expressions constructed above, one by parsing and one by direct construction, are equivalent:

Example

julia> ex1 == ex2
true

Expr objects can also be nested:

Example

julia> ex3 = Meta.parse("(4 + 4) / 2")
:((4 + 4) / 2)

We can also use Meta.show_sexpr to view expressions. The following example demonstrates nested Expr:

Example

julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)

Symbols

We can use the colon:prefix operator to store an unevaluated but parsed expression.

Example

julia> ABC = 100
100

julia> :ABC
:ABC

Quote the entire expression below:

Example

julia> :(100-50)
:(100 - 50)

Quote an arithmetic expression:

Example

julia> ex = :(a+b*c+1)
:(a + b * c + 1)

julia> typeof(ex)
Expr

Note that equivalent expressions can also be constructed using Meta.parse or directly with Expr:

Example

julia>      :(a + b*c + 1)       ==
       Meta.parse("a + b*c + 1") ==
       Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true

Quoting multiple expressions can alsoquote ... endinclude a code block in it.

Example

julia> ex = quote
           x = 1
           y = 2
           x + y
       end
quote
    #= none:2 =#
    x = 1
    #= none:3 =#
    y = 2
    #= none:4 =#
    x + y
end

julia> typeof(ex)
Expr

Executing Expressions

After an expression is parsed, we can use the eval() function to execute it:

Example

julia> ex1 = :(1 + 2)
:(1 + 2)

julia> eval(ex1)
3

julia> ex = :(a + b)
:(a + b)

julia> eval(ex)
ERROR: UndefVarError: b not defined
[...]

julia> a = 1; b = 2;

julia> eval(ex)
3

Abstract Syntax Tree (AST)

An abstract syntax tree (AST) is a structure that is an abstract representation of the grammatical structure of source code.

It represents the syntactic structure of a programming language in tree form; each node on the tree represents a structure in the source code.

We can view the hierarchical structure of an expression with the dump() function:

Example

julia> dump(:(1 * cos(pi/2)))
Expr
   head: Symbol call
   args: Array{Any}((3,))
      1: Symbol *
      2: Int64 1
      3: Expr
         head: Symbol call
         args: Array{Any}((2,))
            1: Symbol cos
            2: Expr
               head: Symbol call
               args: Array{Any}((3,))
                  1: Symbol /
                  2: Symbol pi
                  3: Int64 2

Interpolation

Although constructing Expr objects directly with value parameters is powerful, compared with Julia syntax, the Expr constructor may feel tedious. As an alternative, Julia allows literals or expressions to be inserted into quoted expressions. Expression interpolation is indicated by the prefix $.

In this example, the value of variable a is interpolated:

Example

julia> a = 1;

julia> ex = :($a + b)
:(1 + b)

Interpolating into an unquoted expression is not supported, which causes a compile-time error:

julia> $a + b
ERROR: syntax: "$" expression outside quote

In this example, the tuple (1,2,3) is inserted as an expression into the conditional test:

julia> ex = :(a in $:((1,2,3)) )
:(a in (1, 2, 3))

The use of $ in expression interpolation is deliberately reminiscent of string interpolation and command interpolation. Expression interpolation makes the programmatic construction of complex Julia expressions convenient and readable.

Macro

Macros provide a mechanism for including generated code in the final body of a program. A macro maps a set of arguments to a returned expression, and the generated expression is compiled directly without requiring a runtime eval call. Macro arguments may include expressions, literals, and symbols.

This is a very simple macro:

Example

julia> macro sayhello()
           return :( println("Hello, world!") )
       end
@sayhello (macro with 1 method)

In Julia's syntax, macros have a special character @ (at-sign), immediately followed by a unique macro name declared using the form macro NAME ... end. In this example, the compiler will replace all @sayhello with:

:( println("Hello, world!") )

When @sayhello is entered in the REPL, the interpreter executes it immediately, so we only see the evaluated result:

julia> @sayhello()
Hello, world!

Now, consider a slightly more complex macro:

Example

julia> macro sayhello(name)
 return :( println("Hello, ", $name) )
 end
@sayhello (macro with 1 method)

This macro accepts an argument name. When @sayhello is encountered, the quoted expression is expanded and the value of the argument is inserted into the final expression:

Example

julia> @sayhello("human")
Hello, human

We can use the macroexpand function to view the returned quoted expression:

Example

julia> ex = macroexpand(Main, :(@sayhello("human")) )
:(Main.println("Hello, ", "human"))

julia> typeof(ex)
Expr

We can see that the literal "human" has been inserted into the expression.

There is also a macro @macroexpand, which may be more convenient than the macroexpand function:

Example

julia> @macroexpand @sayhello "human"
:(println("Hello, ", "human"))
Other Extensions