Easy Tutorial
❮ Home Julia Dictionaries Sets ❯

Julia Metaprogramming

Julia represents its code as data structures within the language, allowing us to write programs that manipulate other programs.

Metaprogramming can also be simply understood as writing code that generates other code.

>

Metaprogramming (English: Metaprogramming) refers to the writing of computer programs that can treat other programs (or themselves) as their data or perform part of the work at compile time that would normally be done at runtime. In most cases, this allows programmers to achieve higher productivity or provide greater flexibility to handle new situations without recompiling.

The language used to write metaprograms is called a metalinguistic abstraction. The language of the manipulated program is called the "target language." The ability of a programming language to be its own metalinguistic abstraction is called "reflection" or "self-reference."

-- Wikipedia

Julia Source Code Execution Stages

1. Parse Raw 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 easily manipulable format.

2. Execute Parsed Julia Code: At this stage, the parsed Julia code is executed.

When we type code into an interactive programming environment (REPL) and press Enter, these two stages are executed.

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

Program Representation

Julia provides a Meta class where you can parse a string with Meta.parse(str) and return Expr with typeof(e1):

Example

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

julia> typeof(ex1)
Expr

Returning :(1 + 1), this return value consists of a colon followed by the expression, and typeof(ex1) returns Expr.

An Expr object contains two parts (ex1 has head and args attributes):

A symbol object that identifies the type of the expression.

Example

julia> ex1.head
:call

The other part is the arguments of the expression, which can 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 through parsing and one through 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 the expression, the following example shows a nested Expr:

Example

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

Symbols

We can store an unevaluated but parsed expression by prefixing it with a colon :.

Example

julia> ABC = 100
100

julia> :ABC
:ABC

Quoting the entire expression:

Example

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

Quoting arithmetic expressions:

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

Multiple expressions can also be quoted within a quote ... end block.

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 parsing the expression, we can use the eval() function to execute it:

Example

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

julia> eval(ex1)
3

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

julia> a = 1; b = 2;

julia> eval(ex)
3

Abstract Syntax Tree (AST)

The Abstract Syntax Tree (AST) is a structure that represents the abstract syntactic structure of source code.

It represents the syntactic structure of the program's source code in a tree-like format, where each node in the tree represents a construct 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

Constructing Expr objects directly with arguments can be powerful, but it can be tedious compared to Julia syntax. As an alternative, Julia allows literal or expression interpolation into quoted expressions. Expression interpolation is indicated by a prefix $.

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

Example

julia> a = 1;

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

Interpolation into non-quoted expressions is not supported and results in a compile-time error:

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

In this example, the tuple (1,2,3) is interpolated into a conditional test:

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

Using $ for expression interpolation is intended to resemble string interpolation and command interpolation. Expression interpolation makes it convenient and readable to construct complex Julia expressions programmatically.

Macros

Macros provide a mechanism to include generated code in the final body of a program. Macros map a tuple of arguments to a returned expression, and the resulting expression is compiled directly without requiring runtime eval calls. Macro arguments can include expressions, literals, and symbols.

Here is a very simple macro:

Example

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

Macros in Julia have a dedicated character @ (at-sign), followed by the unique macro name declared using the macro NAME ... end form. In this example, the compiler replaces all occurrences of @sayhello with:

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

When @sayhello is entered in the REPL, the interpreter executes it immediately, so we only see the computed 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 takes one argument name. When @sayhello is encountered, the quoted expression is expanded and the value from the argument is inserted into the final expression:

Example

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

We can use the macroexpand function to see the quoted return 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 might be more convenient than the macroexpand function:

Example

julia> @macroexpand @sayhello "human"

:(println("Hello, ", "human"))

❮ Home Julia Dictionaries Sets ❯