现在位置: 首页 > Julia 教程 > 正文

Julia 元编程

Julia 把自己的代码表示为语言中的数据结构,这样我们就可以编写操纵程序的程序。

元编程也可以简单理解为编写可以生成代码的代码。

元编程(英语:Metaprogramming),是指某类计算机程序的编写,这类计算机程序编写或者操纵其它程序(或者自身)作为它们的资料,或者在编译时完成部分本应在运行时完成的工作。多数情况下,与手工编写全部代码相比,程序员可以获得更高的工作效率,或者给与程序更大的灵活度去处理新的情形而无需重新编译。

编写元程序的语言称之为元语言。被操纵的程序的语言称之为"目标语言"。一门编程语言同时也是自身的元语言的能力称之为"反射"或者"自反"。

-- 维基百科

Julia 源代码执行阶段

Julia 元编程流程:源码到 AST 再到执行

1、解析原始 Julia 代码:Julia 解析器首先将源码字符串解析成抽象语法树(AST),AST 是一种以易于操作的格式包含全部代码的结构。

2、执行已解析的 Julia 代码:在这个阶段,执行已解析的 Julia 代码。

当我们在 REPL 中输入代码并按回车键时,会依次执行以上两个阶段。

使用元编程工具,我们就可以访问这两个阶段之间的 Julia 代码,即在源代码解析之后、但在执行之前。

提醒:元编程是强大的工具,但也增加了代码的理解成本。官方建议只有在高阶函数和闭包无法满足需求时才使用宏和 eval,大多数功能应该优先用普通函数实现。


程序表示

Julia 提供了 Meta 模块,可以用 Meta.parse(str) 把字符串解析成表达式对象,解析结果是 Expr 类型:

实例

julia> prog = "1 + 1"
"1 + 1"

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

julia> typeof(ex1)
Expr

Expr 对象包含两个部分(ex1 包含了 head 和 args 属性):

一个是标识表达式类型的符号(Symbol)对象:

实例

julia> ex1.head
:call

另一个是表达式的参数,可能是符号、其他表达式或字面量:

实例

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

表达式也可以直接用 Expr 构造:

实例

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

# 通过解析和直接构造的两个表达式是等价的
julia> ex1 == ex2
true

# 嵌套表达式
julia> ex3 = Meta.parse("(4 + 4) / 2")
:((4 + 4) / 2)

# 用 S 表达式查看嵌套结构
julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)

符号与引用

冒号 : 前缀在 Julia 中有两个用途,注意区分:

第一,:名字 创建 Symbol(符号)。Symbol 是一种"经过内部驻留的字符串",代表一个名字本身而不是它的值:

实例

julia> ABC = 100
100

julia> :ABC          # 返回符号 :ABC,不是 100
:ABC

julia> typeof(:ABC)
Symbol

第二,:(表达式) 创建未求值的表达式对象(quoting):

实例

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

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

julia> typeof(ex)
Expr

# 等价的三种构造方式
julia> :(a + b*c + 1) == Meta.parse("a + b*c + 1") ==
       Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true

引用多个表达式可以在 quote ... end 中包含代码块:

实例

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

执行表达式

表达式解析后,我们可以使用 eval() 函数在全局作用域中执行它:

实例

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

julia> eval(ex1)
3

# 表达式中的符号在 eval 时才查找
julia> ex = :(a + b)
:(a + b)

julia> eval(ex)
ERROR: UndefVarError: `b` not defined in `Main`

julia> a = 1; b = 2;

julia> eval(ex)
3

抽象语法树(AST)

抽象语法树是源代码语法结构的抽象表示,它以树状形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

我们可以在 dump() 函数查看表达式的层次结构:

实例

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

插值

直接构造 Expr 对象虽然强大,但写起来繁琐。Julia 允许把字面量或表达式的值插入到被引用的表达式中,插值由前缀 $ 表示:

实例

julia> a = 1;

# a 的值被插入表达式
julia> ex = :($a + b)
:(1 + b)

对未被引用的表达式进行插值是不支持的,这会导致编译期错误:

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

插值元组时使用 $((1,2,3)) 形式:

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

表达式插值中的 $ 是有意与字符串插值、命令插值保持一致的,它使复杂 Julia 表达式的程序化构造变得方便和易读。


宏

宏提供了一种机制,可以将生成的代码包含在程序的最终主体中。宏把一组参数(表达式)映射为返回的表达式,返回的表达式被直接编译,而不需要运行时 eval 调用。宏参数可以包括表达式、字面量和符号。

这是一个非常简单的宏:

实例

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

宏在 Julia 的语法中有一个专门的字符 @(at-sign),紧接着是宏名。在这个例子中,编译器会把所有的 @sayhello 替换成:

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

当 @sayhello 在 REPL 中被输入时,解释器立即执行展开后的代码,因此我们只会看到计算后的结果:

julia> @sayhello()
Hello, world!

带参数的宏,用 $ 把参数插值进返回的表达式:

实例

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

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

查看宏展开

我们可以使用 @macroexpand 查看宏展开后的表达式,这是调试宏最有用的工具:

实例

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

# @assert 宏的展开:表达式原样保留,出错时还能打印出表达式文本
julia> @macroexpand @assert 1 == 2
:(if 1 == 2
      nothing
  else
      throw(AssertionError("1 == 2"))
  end)

宏与函数的关键区别:宏在代码解析时执行,函数在运行时执行。宏收到的是代码本身(表达式),函数收到的是计算后的值,因此宏能做到函数做不到的事情,比如在错误信息里显示原表达式。

宏的卫生性(Hygiene)

宏展开时,宏内部定义的变量会被自动重命名,不会和调用处的变量冲突,这称为宏的卫生性。如果宏需要操作调用处的变量,需要用 esc() 显式声明:

实例

# 这个宏会把调用处的 x 设为 0
julia> macro zerox()
           return esc(:(x = 0))
       end
@zerox (macro with 1 method)

julia> function foo()
           x = 1
           @zerox
           return x
       end
foo (generic function with 1 method)

julia> foo()
0

非标准字符串字面量的原理

前面章节用过的 r"..."(正则)、b"..."(字节数组)等非标准字符串字面量,其实就是宏机制:前缀名加 _str 后缀就是对应的宏名。例如正则字面量的实现只有一行:

实例

macro r_str(p)
    Regex(p)
end

r"^\s*$" 会被解析成 @r_str 宏调用,宏在编译期把字符串变成 Regex 对象,比运行时每次 Regex("...") 更高效。


代码生成

当需要大量重复的样板代码时,可以用 @eval 宏在程序内生成定义。例如给自定义类型批量添加方法:

实例

julia> struct MyNumber
           x::Float64
       end

# 批量为 MyNumber 定义 sin/cos/tan/log/exp 方法
julia> for op = (:sin, :cos, :tan, :log, :exp)
           @eval Base.$op(a::MyNumber) = MyNumber($op(a.x))
       end

julia> x = MyNumber(pi)
MyNumber(3.141592653589793)

julia> sin(x)
MyNumber(1.2246467991473532e-16)

julia> cos(x)
MyNumber(-1.0)

这个模式让 Julia 充当了自己的预处理器,可以在语言内部完成代码生成。