元编程

Julia 语言中最强大的 Lisp 遗产是其元编程支持。与 Lisp 一样,Julia 将其自身的代码表示为语言本身的数据结构。由于代码由可以在语言内部创建和操作的对象表示,因此程序可以转换和生成其自身的代码。这允许在没有额外构建步骤的情况下进行复杂的代码生成,并且还允许在抽象语法树级别上进行真正的 Lisp 风格的宏操作。相比之下,像 C 和 C++ 这样的预处理器“宏”系统在任何实际的解析或解释发生之前执行文本操作和替换。因为 Julia 中的所有数据类型和代码都由 Julia 数据结构表示,所以强大的反射功能可用于探索程序及其类型的内部结构,就像任何其他数据一样。

警告

元编程是一个强大的工具,但它引入了复杂性,这可能使代码更难以理解。例如,正确理解作用域规则可能出奇地困难。元编程通常应该只在无法应用其他方法(如高阶函数和闭包)时使用。

eval 和定义新的宏通常应该作为最后的手段使用。使用 Meta.parse 或将任意字符串转换为 Julia 代码几乎从来都不是一个好主意。为了操作 Julia 代码,请直接使用 Expr 数据结构,以避免 Julia 语法解析的复杂性。

元编程的最佳用途通常是在运行时辅助函数中实现其大部分功能,努力最大限度地减少它们生成的代码量。

程序表示

每个 Julia 程序都以字符串的形式开始

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

接下来会发生什么?

下一步是将每个字符串解析为称为表达式的对象,由 Julia 类型 Expr 表示

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

julia> typeof(ex1)
Expr

Expr 对象包含两个部分

  • 一个 Symbol 用于识别表达式的类型。符号是用于标识符的内部字符串(下面将详细讨论)。
julia> ex1.head
:call
  • 表达式的参数,可以是符号、其他表达式或字面量值
julia> ex1.args
3-element Vector{Any}:
  :+
 1
 1

表达式也可以直接以前缀表示法构建

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

上面构造的两个表达式(通过解析和直接构造)是等价的

julia> ex1 == ex2
true

这里的关键点是 Julia 代码在内部表示为一个数据结构,该数据结构可以从语言本身访问。

dump 函数提供 Expr 对象的缩进和带注释的显示

julia> dump(ex2)
Expr
  head: Symbol call
  args: Array{Any}((3,))
    1: Symbol +
    2: Int64 1
    3: Int64 1

Expr 对象也可以嵌套

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

查看表达式的另一种方法是使用 Meta.show_sexpr,它显示给定 Expr 的 S-表达式形式,这可能看起来对 Lisp 用户非常熟悉。以下是一个说明嵌套 Expr 上显示的示例

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

符号

: 字符在 Julia 中有两个语法用途。第一种形式从有效的标识符创建 Symbol,一个内部字符串,用作表达式的构建块之一

julia> s = :foo
:foo

julia> typeof(s)
Symbol

Symbol 构造函数接受任意数量的参数,并通过将它们的字符串表示连接在一起创建一个新的符号

julia> :foo === Symbol("foo")
true

julia> Symbol("1foo") # `:1foo` would not work, as `1foo` is not a valid identifier
Symbol("1foo")

julia> Symbol("func",10)
:func10

julia> Symbol(:var,'_',"sym")
:var_sym

在表达式的上下文中,符号用于指示对变量的访问;当表达式求值时,符号将替换为在适当的作用域中绑定到该符号的值。

有时需要在 : 的参数周围添加额外的括号以避免解析中的歧义

julia> :(:)
:(:)

julia> :(::)
:(::)

表达式和求值

引用

: 字符的第二个语法用途是创建表达式对象,而不使用显式的 Expr 构造函数。这称为引用: 字符后面跟着一对括号,括号中包含一条 Julia 代码语句,会根据包含的代码生成一个 Expr 对象。以下是用短格式引用算术表达式的示例

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

julia> typeof(ex)
Expr

(要查看此表达式的结构,请尝试 ex.headex.args,或如上所述使用 dumpMeta.@dump

请注意,可以使用 Meta.parse 或直接的 Expr 形式构造等价的表达式

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

由解析器提供的表达式通常只有符号、其他表达式和字面量值作为其参数,而由 Julia 代码构造的表达式可以具有任意运行时值,而没有字面量形式作为参数。在此特定示例中,+a 是符号,*(b,c) 是一个子表达式,1 是一个字面量 64 位有符号整数。

还有第二种语法形式的引用,用于多个表达式:用 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

插值

使用值参数直接构造 Expr 对象功能强大,但与“正常”Julia 语法相比,Expr 构造函数可能很繁琐。作为替代方案,Julia 允许将字面量或表达式插值到引用的表达式中。插值由前缀 $ 表示。

在此示例中,插值了变量 a 的值

julia> a = 1;

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 表达式。

展开插值

请注意,$ 插值语法只允许将单个表达式插入到封闭表达式中。有时,您有一个表达式数组,并且需要它们都成为周围表达式的参数。这可以通过 $(xs...) 语法完成。例如,以下代码生成一个函数调用,其中参数的数量由程序确定

julia> args = [:x, :y, :z];

julia> :(f(1, $(args...)))
:(f(1, x, y, z))

嵌套引用

当然,引号表达式可以包含其他引号表达式。理解这些情况下插值是如何工作的可能有点棘手。考虑这个例子

julia> x = :(1 + 2);

julia> e = quote quote $x end end
quote
    #= none:1 =#
    $(Expr(:quote, quote
    #= none:1 =#
    $(Expr(:$, :x))
end))
end

注意结果包含$x,这意味着x还没有被求值。换句话说,$表达式“属于”内部引号表达式,因此它的参数只有在内部引号表达式被求值时才会被求值。

julia> eval(e)
quote
    #= none:1 =#
    1 + 2
end

然而,外部的quote表达式能够插值内部$中的值。这是通过多个$来实现的

julia> e = quote quote $$x end end
quote
    #= none:1 =#
    $(Expr(:quote, quote
    #= none:1 =#
    $(Expr(:$, :(1 + 2)))
end))
end

注意结果中现在出现了(1 + 2),而不是符号x。求值这个表达式会得到一个插值的3

julia> eval(e)
quote
    #= none:1 =#
    3
end

这种行为背后的直觉是,x对于每个$都会被求值一次:一个$的作用类似于eval(:x),给出x的值,而两个$则相当于eval(eval(:x))

QuoteNode

quote形式在AST中的常用表示形式是一个头部为:quoteExpr

julia> dump(Meta.parse(":(1+2)"))
Expr
  head: Symbol quote
  args: Array{Any}((1,))
    1: Expr
      head: Symbol call
      args: Array{Any}((3,))
        1: Symbol +
        2: Int64 1
        3: Int64 2

正如我们所看到的,这样的表达式支持使用$进行插值。但是,在某些情况下,需要执行插值地引用代码。这种类型的引用还没有语法,但在内部表示为QuoteNode类型的对象

julia> eval(Meta.quot(Expr(:$, :(1+2))))
3

julia> eval(QuoteNode(Expr(:$, :(1+2))))
:($(Expr(:$, :(1 + 2))))

解析器会为简单的引用项(如符号)生成QuoteNode

julia> dump(Meta.parse(":x"))
QuoteNode
  value: Symbol x

QuoteNode还可以用于某些高级元编程任务。

表达式求值

给定一个表达式对象,可以使用eval使Julia在全局作用域中对其进行求值(执行)

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

每个模块都有自己的eval函数,用于在其全局作用域中求值表达式。传递给eval的表达式不仅限于返回值——它们还可以具有改变封闭模块环境状态的副作用

julia> ex = :(x = 1)
:(x = 1)

julia> x
ERROR: UndefVarError: `x` not defined

julia> eval(ex)
1

julia> x
1

这里,表达式对象的求值导致一个值被赋值给全局变量x

由于表达式只是可以以编程方式构建然后求值的Expr对象,因此可以动态生成任意代码,然后使用eval运行。这是一个简单的例子

julia> a = 1;

julia> ex = Expr(:call, :+, a, :b)
:(1 + b)

julia> a = 0; b = 2;

julia> eval(ex)
3

a的值用于构建表达式ex,该表达式将+函数应用于值1和变量b。注意ab的使用方式之间的重要区别

  • 表达式构建时变量a的值用作表达式中的直接值。因此,表达式求值时a的值不再重要:表达式中的值已经是1,与a的值可能是什么无关。
  • 另一方面,符号:b用于表达式构建,因此变量b在此时的值无关紧要——:b只是一个符号,变量b甚至不需要定义。然而,在表达式求值时,符号:b的值通过查找变量b的值来解析。

Expr上的函数

如上所述,Julia的一个极其有用的特性是能够在Julia本身内部生成和操作Julia代码。我们已经看到一个函数返回Expr对象的例子:Meta.parse函数,它接收一段Julia代码字符串并返回相应的Expr。一个函数也可以将一个或多个Expr对象作为参数,并返回另一个Expr。这是一个简单且有启发性的例子

julia> function math_expr(op, op1, op2)
           expr = Expr(:call, op, op1, op2)
           return expr
       end
math_expr (generic function with 1 method)

julia>  ex = math_expr(:+, 1, Expr(:call, :*, 4, 5))
:(1 + 4 * 5)

julia> eval(ex)
21

另一个例子,这是一个将任何数字参数加倍但保留表达式的函数

julia> function make_expr2(op, opr1, opr2)
           opr1f, opr2f = map(x -> isa(x, Number) ? 2*x : x, (opr1, opr2))
           retexpr = Expr(:call, op, opr1f, opr2f)
           return retexpr
       end
make_expr2 (generic function with 1 method)

julia> make_expr2(:+, 1, 2)
:(2 + 4)

julia> ex = make_expr2(:+, 1, Expr(:call, :*, 5, 8))
:(2 + 5 * 8)

julia> eval(ex)
42

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

基础

这是一个极其简单的宏

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

宏在Julia的语法中有一个专用的字符:@(at符号),后跟在macro NAME ... end块中声明的唯一名称。在这个例子中,编译器将替换所有@sayhello的实例为

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

当在REPL中输入@sayhello时,表达式会立即执行,因此我们只看到求值结果

julia> @sayhello()
Hello, world!

现在,考虑一个稍微复杂一点的宏

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

这个宏接受一个参数:name。当遇到@sayhello时,引用的表达式会被扩展以将参数的值插值到最终的表达式中

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

我们可以使用函数macroexpand查看引用的返回表达式(**重要说明:**这是调试宏的极其有用的工具)

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

julia> typeof(ex)
Expr

我们可以看到"human"字面量已经被插值到表达式中了。

还有一个宏@macroexpand,它可能比macroexpand函数更方便一些

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

等等:为什么使用宏?

我们已经在上一节中看到了一个函数f(::Expr...) -> Expr。事实上,macroexpand也是这样的一个函数。那么,为什么宏存在呢?

宏是必要的,因为它们在代码被解析时执行,因此,宏允许程序员在完整程序运行之前生成和包含自定义代码片段。为了说明区别,请考虑以下示例

julia> macro twostep(arg)
           println("I execute at parse time. The argument is: ", arg)
           return :(println("I execute at runtime. The argument is: ", $arg))
       end
@twostep (macro with 1 method)

julia> ex = macroexpand(Main, :(@twostep :(1, 2, 3)) );
I execute at parse time. The argument is: :((1, 2, 3))

第一次调用println是在调用macroexpand时执行的。生成的表达式包含第二个println

julia> typeof(ex)
Expr

julia> ex
:(println("I execute at runtime. The argument is: ", $(Expr(:copyast, :($(QuoteNode(:((1, 2, 3)))))))))

julia> eval(ex)
I execute at runtime. The argument is: (1, 2, 3)

宏调用

宏使用以下通用语法调用

@name expr1 expr2 ...
@name(expr1, expr2, ...)

注意宏名称之前的区别性@以及第一种形式中参数表达式之间缺少逗号,以及第二种形式中@name之后缺少空格。这两种风格不应混用。例如,以下语法与上面的示例不同;它将元组(expr1, expr2, ...)作为一个参数传递给宏

@name (expr1, expr2, ...)

另一种在数组字面量(或推导式)上调用宏的方法是将两者并列而不使用括号。在这种情况下,数组将是传递给宏的唯一表达式。以下语法是等价的(并且不同于@name [a b] * v

@name[a b] * v
@name([a b]) * v

需要强调的是,宏接收其参数作为表达式、字面量或符号。探索宏参数的一种方法是在宏主体中调用show函数

julia> macro showarg(x)
           show(x)
           # ... remainder of macro, returning an expression
       end
@showarg (macro with 1 method)

julia> @showarg(a)
:a

julia> @showarg(1+1)
:(1 + 1)

julia> @showarg(println("Yo!"))
:(println("Yo!"))

除了给定的参数列表外,每个宏都会传递名为__source____module__的额外参数。

参数__source__提供有关宏调用中@符号的解析器位置的信息(以LineNumberNode对象的形式)。这允许宏包含更好的错误诊断信息,并且通常由日志记录、字符串解析器宏和文档使用,例如,以及实现@__LINE__@__FILE__@__DIR__宏。

可以通过引用__source__.line__source__.file访问位置信息

julia> macro __LOCATION__(); return QuoteNode(__source__); end
@__LOCATION__ (macro with 1 method)

julia> dump(
            @__LOCATION__(
       ))
LineNumberNode
  line: Int64 2
  file: Symbol none

参数__module__提供有关宏调用扩展上下文的信息(以Module对象的形式)。这允许宏查找上下文信息,例如现有的绑定,或将该值作为额外参数插入到执行自反射的运行时函数调用中,以获取当前模块中的信息。

构建高级宏

以下是Julia的@assert宏的简化定义

julia> macro assert(ex)
           return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
       end
@assert (macro with 1 method)

这个宏可以这样使用

julia> @assert 1 == 1.0

julia> @assert 1 == 0
ERROR: AssertionError: 1 == 0

在书写语法的位置,宏调用在解析时被扩展到其返回的结果。这相当于写

1 == 1.0 ? nothing : throw(AssertionError("1 == 1.0"))
1 == 0 ? nothing : throw(AssertionError("1 == 0"))

也就是说,在第一次调用中,表达式:(1 == 1.0)被拼接进测试条件槽中,而string(:(1 == 1.0))的值被拼接进断言消息槽中。这样构造的整个表达式被放置到@assert宏调用发生位置的语法树中。然后在执行时,如果测试表达式求值为真,则返回nothing,而如果测试为假,则引发错误,指示为假的断言表达式。请注意,这不可能写成函数,因为只有条件的可用,并且不可能在错误消息中显示计算它的表达式。

Julia Base中@assert的实际定义更复杂。它允许用户选择性地指定他们自己的错误消息,而不仅仅是打印失败的表达式。就像具有可变数量参数的函数(可变参数函数)一样,这由最后一个参数后的省略号指定

julia> macro assert(ex, msgs...)
           msg_body = isempty(msgs) ? ex : msgs[1]
           msg = string(msg_body)
           return :($ex ? nothing : throw(AssertionError($msg)))
       end
@assert (macro with 1 method)

现在@assert有两种操作模式,这取决于它接收的参数数量!如果只有一个参数,则由msgs捕获的表达式元组将为空,并且它的行为与上面更简单的定义相同。但是现在如果用户指定第二个参数,它将被打印在消息体中而不是失败的表达式中。您可以使用恰如其分地命名的@macroexpand宏检查宏扩展的结果

julia> @macroexpand @assert a == b
:(if Main.a == Main.b
        Main.nothing
    else
        Main.throw(Main.AssertionError("a == b"))
    end)

julia> @macroexpand @assert a==b "a should equal b!"
:(if Main.a == Main.b
        Main.nothing
    else
        Main.throw(Main.AssertionError("a should equal b!"))
    end)

还有一个情况是实际的 @assert 宏处理的:如果除了打印“a 应该等于 b”,我们还想打印它们的值呢?有人可能会天真地尝试在自定义消息中使用字符串插值,例如 @assert a==b "a ($a) should equal b ($b)!",但这在使用上述宏时不会按预期工作。你能明白为什么吗?回顾 字符串插值,插值字符串被重写为对 string 的调用。比较一下

julia> typeof(:("a should equal b"))
String

julia> typeof(:("a ($a) should equal b ($b)!"))
Expr

julia> dump(:("a ($a) should equal b ($b)!"))
Expr
  head: Symbol string
  args: Array{Any}((5,))
    1: String "a ("
    2: Symbol a
    3: String ") should equal b ("
    4: Symbol b
    5: String ")!"

因此,现在 msg_body 中不再是简单的字符串,宏接收的是一个完整的表达式,需要对其进行求值才能按预期显示。这可以直接拼接到返回的表达式中,作为 string 调用的参数;有关完整实现,请参阅 error.jl

@assert 宏充分利用了拼接到带引号的表达式中的功能,简化了宏主体内部表达式的操作。

卫生

在更复杂的宏中出现的一个问题是 卫生。简而言之,宏必须确保它们在其返回的表达式中引入的变量不会意外地与它们扩展到的周围代码中的现有变量发生冲突。相反,作为参数传递给宏的表达式通常 预期 在周围代码的上下文中求值,与现有变量交互并修改它们。另一个问题源于宏可能在与定义它的模块不同的模块中被调用。在这种情况下,我们需要确保所有全局变量都解析到正确的模块。Julia 已经比使用文本宏扩展(如 C)的语言具有很大的优势,因为它只需要考虑返回的表达式。所有其他变量(例如上面 @assert 中的 msg)遵循 正常的变量作用域块行为

为了演示这些问题,让我们考虑编写一个 @time 宏,它将表达式作为参数,记录时间,求值表达式,再次记录时间,打印前后时间之间的差值,然后将表达式的值作为其最终值。宏可能如下所示

macro time(ex)
    return quote
        local t0 = time_ns()
        local val = $ex
        local t1 = time_ns()
        println("elapsed time: ", (t1-t0)/1e9, " seconds")
        val
    end
end

这里,我们希望 t0t1val 是私有的临时变量,并且我们希望 time_ns 指的是 Julia Base 中的 time_ns 函数,而不是用户可能拥有的任何 time_ns 变量(println 也是如此)。想象一下,如果用户表达式 ex 也包含对名为 t0 的变量的赋值,或者定义了自己的 time_ns 变量,会发生什么问题。我们可能会遇到错误,或者出现神秘的错误行为。

Julia 的宏扩展器以以下方式解决了这些问题。首先,宏结果中的变量被分类为局部变量或全局变量。如果对变量进行了赋值(并且没有声明为全局变量)、声明为局部变量或用作函数参数名称,则该变量被视为局部变量。否则,它被视为全局变量。然后将局部变量重命名为唯一名称(使用 gensym 函数,该函数生成新的符号),并且全局变量在宏定义环境中解析。因此,上述两个问题都得到了解决;宏的局部变量不会与任何用户变量冲突,并且 time_nsprintln 将引用 Julia Base 定义。

然而,仍然存在一个问题。考虑以下宏用法

module MyModule
import Base.@time

time_ns() = ... # compute something

@time time_ns()
end

这里用户表达式 ex 是对 time_ns 的调用,但不是宏使用的同一个 time_ns 函数。它显然指的是 MyModule.time_ns。因此,我们必须安排 ex 中的代码在宏调用环境中解析。这是通过使用 esc “转义”表达式来完成的

macro time(ex)
    ...
    local val = $(esc(ex))
    ...
end

以这种方式包装的表达式会被宏扩展器保留,并逐字粘贴到输出中。因此,它将在宏调用环境中解析。

这种转义机制可用于在必要时“违反”卫生规则,以引入或操作用户变量。例如,以下宏在调用环境中将 x 设置为零

julia> macro zerox()
           return esc(:(x = 0))
       end
@zerox (macro with 1 method)

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

julia> foo()
0

这种变量操作应该谨慎使用,但偶尔非常方便。

正确理解卫生规则可能是一项艰巨的挑战。在使用宏之前,您可能需要考虑函数闭包是否足够。另一种有用的策略是尽可能将工作推迟到运行时。例如,许多宏只是将其参数包装在 QuoteNode 或其他类似的 Expr 中。其中一些示例包括 @task body,它只是返回 schedule(Task(() -> $body)),以及 @eval expr,它只是返回 eval(QuoteNode(expr))

为了演示,我们可以将上面的 @time 示例重写为

macro time(expr)
    return :(timeit(() -> $(esc(expr))))
end
function timeit(f)
    t0 = time_ns()
    val = f()
    t1 = time_ns()
    println("elapsed time: ", (t1-t0)/1e9, " seconds")
    return val
end

但是,我们这样做是有充分理由的:将 expr 包装在新作用域块(匿名函数)中也会稍微改变表达式的含义(其中任何变量的作用域),而我们希望 @time 能够以最小的影响使用包装的代码。

宏和分派

宏与 Julia 函数一样,是泛型的。这意味着它们也可以有多个方法定义,这要归功于多重分派

julia> macro m end
@m (macro with 0 methods)

julia> macro m(args...)
           println("$(length(args)) arguments")
       end
@m (macro with 1 method)

julia> macro m(x,y)
           println("Two arguments")
       end
@m (macro with 2 methods)

julia> @m "asd"
1 arguments

julia> @m 1 2
Two arguments

但是,应该记住,宏分派是基于传递给宏的 AST 的类型,而不是 AST 在运行时求值的结果类型

julia> macro m(::Int)
           println("An Integer")
       end
@m (macro with 3 methods)

julia> @m 2
An Integer

julia> x = 2
2

julia> @m x
1 arguments

代码生成

当需要大量重复的样板代码时,通常会以编程方式生成它以避免冗余。在大多数语言中,这需要额外的构建步骤,以及一个单独的程序来生成重复的代码。在 Julia 中,表达式插值和 eval 允许这种代码生成在程序执行的正常过程中进行。例如,考虑以下自定义类型

struct MyNumber
    x::Float64
end
# output

我们希望为其添加多个方法。我们可以在以下循环中以编程方式执行此操作

for op = (:sin, :cos, :tan, :log, :exp)
    eval(quote
        Base.$op(a::MyNumber) = MyNumber($op(a.x))
    end)
end
# output

现在我们可以使用这些函数和我们的自定义类型了

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

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

julia> cos(x)
MyNumber(-1.0)

以这种方式,Julia 充当它自己的 预处理器,并允许从语言内部进行代码生成。上述代码可以使用 : 前缀引用形式更简洁地编写

for op = (:sin, :cos, :tan, :log, :exp)
    eval(:(Base.$op(a::MyNumber) = MyNumber($op(a.x))))
end

但是,这种在语言内部进行代码生成的方式,使用 eval(quote(...)) 模式,非常常见,因此 Julia 带有一个宏来缩写此模式

for op = (:sin, :cos, :tan, :log, :exp)
    @eval Base.$op(a::MyNumber) = MyNumber($op(a.x))
end

@eval 宏将此调用重写为与上述较长版本完全等效。对于较长的生成的代码块,给定到 @eval 的表达式参数可以是一个代码块

@eval begin
    # multiple lines
end

非标准字符串字面量

回顾 字符串,以标识符为前缀的字符串字面量称为非标准字符串字面量,并且可以具有与未加前缀的字符串字面量不同的语义。例如

也许令人惊讶的是,这些行为不是硬编码到 Julia 解析器或编译器中的。相反,它们是由任何人都可以使用的一般机制提供的自定义行为:带前缀的字符串字面量被解析为对特殊命名的宏的调用。例如,正则表达式宏就是以下内容

macro r_str(p)
    Regex(p)
end

就是这样。这个宏表示字符串字面量 r"^\s*(?:#|$)" 的字面内容应该传递给 @r_str 宏,并且该扩展的结果应该放在字符串字面量出现的位置的语法树中。换句话说,表达式 r"^\s*(?:#|$)" 等效于将以下对象直接放入语法树中

Regex("^\\s*(?:#|\$)")

字符串字面量形式不仅更短、更方便,而且效率更高:由于正则表达式是在 代码编译 时编译的,并且 Regex 对象实际上是在代码编译时创建的,因此编译只发生一次,而不是每次执行代码时都发生。考虑一下如果正则表达式出现在循环中

for line = lines
    m = match(r"^\s*(?:#|$)", line)
    if m === nothing
        # non-comment
    else
        # comment
    end
end

由于正则表达式 r"^\s*(?:#|$)" 在此代码被解析时被编译并插入到语法树中,因此表达式只编译一次,而不是每次执行循环时都编译一次。为了在不使用宏的情况下实现这一点,必须像这样编写此循环

re = Regex("^\\s*(?:#|\$)")
for line = lines
    m = match(re, line)
    if m === nothing
        # non-comment
    else
        # comment
    end
end

此外,如果编译器无法确定正则表达式对象在所有循环中都是常量,则某些优化可能无法执行,这使得此版本仍然不如上面更方便的字面量形式高效。当然,在某些情况下,非字面量形式更方便:如果需要将变量插值到正则表达式中,则必须采用这种更详细的方法;在正则表达式模式本身是动态的、可能在每次循环迭代时发生变化的情况下,必须在每次迭代时构造一个新的正则表达式对象。然而,在绝大多数用例中,正则表达式不是基于运行时数据构造的。在这些大多数情况下,能够将正则表达式写成编译时值是无价的。

用户定义字符串字面量的机制非常强大。不仅 Julia 的非标准字面量是使用它实现的,而且命令字面量语法(`echo "Hello, $person"`)也是使用以下看似无害的宏实现的

macro cmd(str)
    :(cmd_gen($(shell_parse(str)[1])))
end

当然,此宏定义中使用的函数隐藏了大量复杂性,但它们只是函数,完全用 Julia 编写。您可以阅读它们的源代码并准确了解它们的作用——它们所做的只是构建要插入程序语法树的表达式对象。

与字符串字面量类似,命令字面量也可以以标识符为前缀,形成所谓的非标准命令字面量。这些命令字面量被解析为对特殊命名的宏的调用。例如,语法 custom`literal` 被解析为 @custom_cmd "literal"。Julia 本身不包含任何非标准命令字面量,但包可以使用此语法。除了不同的语法和 _cmd 后缀而不是 _str 后缀之外,非标准命令字面量的行为与非标准字符串字面量完全相同。

如果两个模块提供了名称相同的非标准字符串或命令字面量,则可以使用模块名称限定字符串或命令字面量。例如,如果 FooBar 都提供了非标准字符串字面量 @x_str,那么可以编写 Foo.x"literal"Bar.x"literal" 来区分两者。

另一种定义宏的方式如下

macro foo_str(str, flag)
    # do stuff
end

然后可以使用以下语法调用此宏

foo"str"flag

上述语法中标志的类型将是String,其内容为字符串字面量之后的所有内容。

生成的函数

一个非常特殊的宏是@generated,它允许你定义所谓的生成函数。这些函数能够根据其参数的类型生成专门的代码,与多重分派相比,具有更大的灵活性或更少的代码。虽然宏在解析时处理表达式,并且无法访问其输入的类型,但生成函数在知道参数类型但函数尚未编译时展开。

生成函数声明不是执行某些计算或操作,而是返回一个带引号的表达式,该表达式随后构成对应于参数类型的函数体。当调用生成函数时,它返回的表达式会被编译并执行。为了提高效率,结果通常会被缓存。为了使之可推断,语言中只有一小部分子集可用。因此,生成函数提供了一种灵活的方法,可以将工作从运行时转移到编译时,但代价是在允许的结构上施加了更大的限制。

在定义生成函数时,与普通函数相比有五个主要区别

  1. 你使用@generated宏来注释函数声明。这会向AST添加一些信息,让编译器知道这是一个生成函数。
  2. 在生成函数的函数体内,你只能访问参数的类型,而不是它们的值。
  3. 你返回一个带引号的表达式,而不是计算某些东西或执行某些操作,当该表达式被求值时,它会执行你想要的操作。
  4. 生成函数只能调用在生成函数定义之前定义的函数。(不遵守此规则可能会导致出现引用未来世界时代的函数的MethodErrors。)
  5. 生成函数不得修改观察任何非常量全局状态(包括例如IO、锁、非局部字典或使用hasmethod)。这意味着它们只能读取全局常量,并且不能有任何副作用。换句话说,它们必须是完全纯的。由于实现限制,这也意味着它们目前不能定义闭包或生成器。

用一个例子来说明这一点最容易。我们可以声明一个生成函数foo如下

julia> @generated function foo(x)
           Core.println(x)
           return :(x * x)
       end
foo (generic function with 1 method)

请注意,函数体返回一个带引号的表达式,即:(x * x),而不是仅仅返回x * x的值。

从调用者的角度来看,这与普通函数相同;事实上,你不需要知道你是在调用普通函数还是生成函数。让我们看看foo是如何工作的

julia> x = foo(2); # note: output is from println() statement in the body
Int64

julia> x           # now we print x
4

julia> y = foo("bar");
String

julia> y
"barbar"

因此,我们看到在生成函数的函数体内,x是传递参数的类型,生成函数返回的值是求值我们从定义中返回的带引号的表达式得到的结果,现在使用x

如果我们再次使用已经使用过的类型求值foo会发生什么?

julia> foo(4)
16

请注意,没有打印Int64。我们可以看到,生成函数的函数体在这里只执行了一次,对于特定的一组参数类型,并且结果被缓存了。之后,对于此示例,第一次调用时从生成函数返回的表达式被重新用作函数体。但是,实际的缓存行为是实现定义的性能优化,因此过分依赖此行为是无效的。

生成函数生成的次数可能只有一次,但也可能更多次,或者根本不发生。因此,你永远不应该编写具有副作用的生成函数——副作用何时以及发生的频率是未定义的。(这对宏也适用——并且就像宏一样,在生成函数中使用eval表示你做错了事情。)但是,与宏不同的是,运行时系统无法正确处理对eval的调用,因此不允许这样做。

了解@generated函数如何与方法重定义交互也很重要。遵循正确的@generated函数不得观察任何可变状态或导致任何全局状态发生变异的原则,我们看到了以下行为。请注意,生成函数不能调用在生成函数本身的定义之前未定义的任何方法。

最初f(x)有一个定义

julia> f(x) = "original definition";

定义其他使用f(x)的操作

julia> g(x) = f(x);

julia> @generated gen1(x) = f(x);

julia> @generated gen2(x) = :(f(x));

我们现在为f(x)添加一些新的定义

julia> f(x::Int) = "definition for Int";

julia> f(x::Type{Int}) = "definition for Type{Int}";

并比较这些结果的不同之处

julia> f(1)
"definition for Int"

julia> g(1)
"definition for Int"

julia> gen1(1)
"original definition"

julia> gen2(1)
"definition for Int"

生成函数的每个方法都有其自己定义函数的视图

julia> @generated gen1(x::Real) = f(x);

julia> gen1(1)
"definition for Type{Int}"

上面示例中的生成函数foo没有做任何普通函数foo(x) = x * x不能做的事情(除了在第一次调用时打印类型并产生更高的开销)。但是,生成函数的强大之处在于它能够根据传递给它的类型计算不同的带引号的表达式

julia> @generated function bar(x)
           if x <: Integer
               return :(x ^ 2)
           else
               return :(x)
           end
       end
bar (generic function with 1 method)

julia> bar(4)
16

julia> bar("baz")
"baz"

(当然,这个人为的例子更容易使用多重分派来实现……)

滥用此功能将破坏运行时系统并导致未定义的行为

julia> @generated function baz(x)
           if rand() < .9
               return :(x^2)
           else
               return :("boo!")
           end
       end
baz (generic function with 1 method)

由于生成函数的函数体是非确定性的,因此其行为以及所有后续代码的行为都是未定义的。

不要复制这些示例!

希望这些示例有助于说明生成函数的工作原理,包括定义端和调用站点;但是,不要复制它们,原因如下

  • foo函数有副作用(对Core.println的调用),并且这些副作用何时、多久或发生多少次是未定义的
  • bar函数解决了一个可以使用多重分派更好地解决的问题——定义bar(x) = xbar(x::Integer) = x ^ 2将做同样的事情,但它更简单也更快。
  • baz函数是病态的

请注意,不应该在生成函数中尝试的操作集是无限的,并且运行时系统目前只能检测到一部分无效操作。还有许多其他操作只会默默地破坏运行时系统,通常以与错误定义没有明显关联的微妙方式。因为函数生成器在推理期间运行,所以它必须尊重该代码的所有限制。

一些不应该尝试的操作包括

  1. 缓存本地指针。

  2. 以任何方式与Core.Compiler的内容或方法交互。

  3. 观察任何可变状态。

    • 生成函数上的推理可能在任何时间运行,包括你的代码试图观察或修改此状态时。
  4. 获取任何锁:你调用的C代码可能在内部使用锁(例如,调用malloc没有问题,即使大多数实现都需要内部使用锁),但不要尝试在执行Julia代码时持有或获取任何锁。

  5. 调用任何在生成函数函数体之后定义的函数。对于增量加载的预编译模块,此条件会放宽,以允许调用模块中的任何函数。

好了,现在我们对生成函数的工作原理有了更好的了解,让我们使用它们来构建一些更高级(且有效)的功能……

高级示例

Julia的基本库有一个内部的sub2ind函数,用于根据一组n个多线性索引计算n维数组中的线性索引——换句话说,计算可以用来使用A[i]而不是A[x,y,z,...]索引数组A的索引i。一个可能的实现如下

julia> function sub2ind_loop(dims::NTuple{N}, I::Integer...) where N
           ind = I[N] - 1
           for i = N-1:-1:1
               ind = I[i]-1 + dims[i]*ind
           end
           return ind + 1
       end
sub2ind_loop (generic function with 1 method)

julia> sub2ind_loop((3, 5), 1, 2)
4

可以使用递归完成相同的事情

julia> sub2ind_rec(dims::Tuple{}) = 1;

julia> sub2ind_rec(dims::Tuple{}, i1::Integer, I::Integer...) =
           i1 == 1 ? sub2ind_rec(dims, I...) : throw(BoundsError());

julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer) = i1;

julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer, I::Integer...) =
           i1 + dims[1] * (sub2ind_rec(Base.tail(dims), I...) - 1);

julia> sub2ind_rec((3, 5), 1, 2)
4

这两种实现虽然不同,但本质上做的是相同的事情:在数组的维度上进行运行时循环,将每个维度中的偏移量收集到最终索引中。

但是,我们循环所需的所有信息都嵌入在参数的类型信息中。这允许编译器将迭代移动到编译时并完全消除运行时循环。我们可以利用生成函数来实现类似的效果;用编译器术语来说,我们使用生成函数手动展开循环。函数体几乎相同,但不是计算线性索引,而是构建一个表达式来计算索引

julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end
sub2ind_gen (generic function with 1 method)

julia> sub2ind_gen((3, 5), 1, 2)
4

此代码将生成什么代码?

一个简单的查找方法是将函数体提取到另一个(普通)函数中

julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           return sub2ind_gen_impl(dims, I...)
       end
sub2ind_gen (generic function with 1 method)

julia> function sub2ind_gen_impl(dims::Type{T}, I...) where T <: NTuple{N,Any} where N
           length(I) == N || return :(error("partial indexing is unsupported"))
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end
sub2ind_gen_impl (generic function with 1 method)

我们现在可以执行sub2ind_gen_impl并检查它返回的表达式

julia> sub2ind_gen_impl(Tuple{Int,Int}, Int, Int)
:(((I[1] - 1) + dims[1] * (I[2] - 1)) + 1)

因此,这里将使用的函数体根本不包含循环——只是索引到两个元组、乘法和加法/减法。所有循环都在编译时执行,并且我们完全避免了在执行期间循环。因此,我们只循环每个类型一次,在本例中,对于每个N循环一次(除了函数生成多次的极端情况——请参见上面的免责声明)。

可选生成函数

生成函数可以在运行时实现高效率,但会带来编译时成本:必须为每个具体参数类型的组合生成一个新的函数体。通常,Julia能够编译适用于任何参数的函数的“通用”版本,但在生成函数中这是不可能的。这意味着大量使用生成函数的程序可能无法进行静态编译。

为了解决这个问题,语言提供了语法来编写生成函数的普通、非生成替代实现。应用于上面的sub2ind示例,它将如下所示

function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
    if N != length(I)
        throw(ArgumentError("Number of dimensions must match number of indices."))
    end
    if @generated
        ex = :(I[$N] - 1)
        for i = (N - 1):-1:1
            ex = :(I[$i] - 1 + dims[$i] * $ex)
        end
        return :($ex + 1)
    else
        ind = I[N] - 1
        for i = (N - 1):-1:1
            ind = I[i] - 1 + dims[i]*ind
        end
        return ind + 1
    end
end

在内部,此代码创建了函数的两个实现:一个生成实现,其中使用if @generated中的第一个块,以及一个普通实现,其中使用else块。在if @generated块的then部分内部,代码与其他生成函数具有相同的语义:参数名称指的是类型,并且代码应该返回一个表达式。可能出现多个if @generated块,在这种情况下,生成实现使用所有then块,而备用实现使用所有else块。

请注意,我们在函数顶部添加了一个错误检查。此代码将对这两个版本通用,并且在两个版本中都是运行时代码(它将被引用并作为表达式从生成版本返回)。这意味着在代码生成时无法获得局部变量的值和类型——代码生成代码只能看到参数的类型。

在这种定义风格中,代码生成功能本质上是一个可选的优化。编译器将在方便时使用它,但在其他情况下可以选择使用普通实现。这种风格是首选,因为它允许编译器做出更多决策并以更多方式编译程序,并且因为普通代码比代码生成代码更易读。但是,使用哪种实现取决于编译器实现细节,因此这两个实现必须具有相同的行为至关重要。