Wait the light to fall

元编程

焉知非鱼

Metaprogramming

元编程

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

程序表示 #

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

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

接下来会发生什么?

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

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

julia> typeof(ex1)
Expr

Expr对象包含两部分。

a 符号标识表达式的种类。符号是一个内嵌的字符串标识符(下文将详细讨论)。

julia> ex1.head
:call

表达式参数,可以是符号、其他表达式或文字值。

julia> ex1.args
3-element Array{Any,1}:
  :+
 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> :foo
:foo

julia> typeof(ans)
Symbol

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

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

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

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

请注意,要使用 : 语法,符号的名称必须是一个有效的标识符。否则必须使用Symbol(str)构造函数。

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

有时,为了避免在解析时产生歧义,需要在 : 的参数周围加括号。

julia> :(:)
:(:)

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

表达式和评价

引用

字符的第二个语法目的是在不使用显式Expr构造函数的情况下创建表达式对象。这就是所谓的引用。在Julia代码的单条语句周围,用成对的括号跟上 : 字符,就可以根据所附的代码生成一个Expr对象。下面是用于引用一个算术表达式的简短形式的例子。

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

julia> typeof(ex)
Expr

(要查看这个表达式的结构,可以试试ex.head和ex.args,或者使用上面的dump或Meta.@dump)

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

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

解析器提供的表达式一般只有符号、其他表达式和字面值作为其args,而Julia代码构建的表达式可以有任意的运行时值,没有字面形式作为args。在这个具体的例子中,+和a是符号,*(b,c)是一个子表达式,1是一个64位有符号整数的文字形式。

对于多个表达式,还有第二种引用的语法形式:用引号……结尾括起来的代码块。

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

但是,外引号表达式能够对内引号中的$内的值进行插值。这是用多个$来完成的。

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

引号形式在AST中的通常表示是一个带头:quote的Expr。

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))))

该解析器产生的QuoteNodes用于简单的引用项目,如符号。

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

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

评估表达式

给定一个表达式对象,可以使用eval使Julia在全局范围内对其进行评估(执行)。

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

julia> eval(ans)
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,注意a和b的使用方式的重要区别。

变量a的值在表达式构造时被用作表达式中的即时值。因此,当表达式被评估时,a的值不再重要:表达式中的值已经是1,无论a的值是多少,都是独立的。 另一方面,在表达式的构造中使用了符号:b,所以变量b的值在那个时候是无关紧要的–:b只是一个符号,变量b甚至不需要被定义。但在表达式评估时,符号:b的值是通过查找变量b的值来解决的。 表达式上的函数

如上所述,Julia的一个极其有用的特性是在Julia本身内部生成和操作Julia代码的能力。我们已经看到了一个函数返回Expr对象的例子: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))

当调用 macroexpand 时,会执行对 println 的第一次调用。结果的表达式只包含第二个 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, ...)

注意在宏名前有区别的 @,第一种形式的参数表达式之间没有逗号,第二种形式的 @ 名后没有空格。两种样式不能混用。例如,下面的语法与上面的例子不同,它将元组 (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 的实际定义比较复杂。它允许用户有选择地指定自己的错误信息,而不是只打印失败的表达式。就像在参数数可变的函数(Varargs Functions)中一样,在最后一个参数后面用省略号来指定。

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 捕获的表达式元组将是空的,它的行为和上面的简单定义一样。如果只有一个参数,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 should equal b” 之外,我们还想打印它们的值呢?人们可能会天真地尝试在自定义消息中使用字符串插值,例如,@assert a==b "a ($a) should equal b ($b)!",但这在上面的宏中不会像预期的那样工作。你能明白为什么吗?从字符串插值回想一下,插值后的字符串会被改写成对字符串的调用。比较一下。

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 中得到一个普通的字符串,而是接收一个完整的表达式,这个表达式需要被评估,以便按照预期的方式显示。这可以直接拼接到返回的表达式中,作为字符串调用的一个参数;完整的实现请参见 error.jl

@assert 宏很好地利用了拼接成引号的表达式,简化了宏体内部对表达式的操作。

卫生宏 #

在比较复杂的宏中会出现一个问题,那就是卫生问题。简而言之,宏必须确保它们在返回的表达式中引入的变量不会意外地与它们扩展到的周围代码中的现有变量发生冲突。相反,作为参数传入宏中的表达式往往要在周围代码的上下文中进行评估,与现有变量进行交互和修改。另一个关注点来自于一个事实,即一个宏可能会在与它被定义的模块不同的地方被调用。在这种情况下,我们需要确保所有的全局变量被解析到正确的模块中。与具有文本宏扩展的语言(如C语言)相比,Julia 已经有了很大的优势,因为它只需要考虑返回的表达式。所有其他的变量(比如上面 @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 引用 Julia Base 中的 time 函数,而不是用户可能拥有的任何 time 变量(同样适用于 println)。想象一下,如果用户表达式 ex 也包含了对一个叫 t0 的变量的赋值,或者定义了自己的 time 变量,可能会出现的问题。我们可能会得到错误,或者神秘的不正确行为。

Julia 的宏扩展器用以下方式解决了这些问题。首先,宏结果中的变量被分为局部或全局。如果一个变量被分配给(而不是声明为全局)、声明为局部,或者被用作函数参数名,那么它被认为是局部的。否则,它被认为是全局变量。局部变量就会被重命名为唯一的(使用 gensym 函数,生成新的符号),全局变量则在宏定义环境中解决。因此,上述两个问题都得到了处理;宏的局部变量不会与任何用户变量冲突,timeprintln 将引用 Julia Base 定义。

然而,仍然存在一个问题。考虑下面这个宏的使用。

module MyModule
import Base.@time

time() = ... # compute something

@time time()
end

这里的用户表达式 ex 是对 time 的调用,但不是宏使用的那个 time 函数。它显然是指 MyModule.time。因此我们必须安排 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

非标准字符串字面值 #

Strings 中回想一下,以标识符为前缀的字符串字元称为非标准字符串字元,其语义可能与未加前缀的字符串字面值不同。例如

r”^\s*(?:#|$) “产生一个正则表达式对象,而不是一个字符串。 b “DATA\xff\u2200 “是一个[68,65,84,65,255,226,136,128]的字节数组文字。 也许令人惊讶的是,这些行为并没有被硬编码到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”^/ds*(?:#|$) “在解析这段代码时被编译并插入语法树中,所以该表达式只被编译一次,而不是每次循环执行时都被编译。为了在不使用宏的情况下实现这个目标,必须这样写这个循环。

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

此外,如果编译器不能确定regex对象在所有循环中都是恒定的,某些优化可能就无法实现,这就使得这个版本的效率仍然不如上面更方便的文字形式。当然,在有些情况下,非字面形式还是比较方便的:如果需要在正则表达式中插入一个变量,就必须采取这种比较啰嗦的方式;在正则表达式模式本身是动态的,有可能在每次循环迭代时发生变化的情况下,必须在每次迭代时构造一个新的正则表达式对象。然而,在绝大多数用例中,正则表达式并不是基于运行时数据来构造的。在这大多数情况下,将正则表达式写成编译时值的能力是非常宝贵的。

与非标准字符串字元一样,非标准命令字元也是使用命令字元语法的前缀变体存在的。命令字元customliteral被解析为@custom_cmd “literal”。Julia本身不包含任何非标准的命令字元,但包可以利用这种语法。除了语法不同和用_cmd后缀代替_str后缀外,非标准命令字元的行为与非标准字符串字元完全相同。

如果两个模块提供了名称相同的非标准字符串或命令字元,可以用模块名称来限定字符串或命令字元。例如,如果Foo和Bar都提供了非标准的字符串字元@x_str,那么可以写成Foo.x “字元 “或Bar.x “字元 “来区分两者。

用户定义字符串字元的机制是深刻的、强大的。不仅Julia的非标准字元用它来实现,而且命令字元语法(echo "Hello, $person")也用下面这个看似无害的宏来实现。

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

当然,这个宏定义中使用的函数中隐藏着大量的复杂性,但它们只是函数,完全是用Julia编写的。你可以阅读它们的源码,并精确地看到它们的作用–它们所做的只是构造表达式对象,以便插入到你的程序的语法树中。

生成的函数

一个非常特殊的宏是@generated,它允许你定义所谓的生成函数。这些函数能够根据其参数的类型生成专门的代码,其灵活性和/或代码量比使用多重调度时更少。宏在解析时与表达式一起工作,不能访问其输入的类型,而生成函数在参数类型已知时得到扩展,但函数尚未编译。

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

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

你用 @generated 宏来注释函数声明。这在AST中添加了一些信息,让编译器知道这是一个生成函数。 在生成函数的主体中,你只能访问参数的类型,而不能访问它们的值。 你不是计算一些东西或执行一些操作,而是返回一个引号的表达式,当它被评估时,就会执行你想要的东西。 生成函数只允许调用在生成函数定义之前定义的函数。(如果不遵守这一点,可能会得到引用未来世界时代函数的MethodErrors。) 生成的函数不得突变或观察任何非常态的全局状态(包括,例如,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的调用,所以不允许使用它。

同样重要的是看@生成函数如何与方法重新定义交互。按照正确的@生成函数不能观察任何可突变的状态或引起任何全局状态的突变的原则,我们看到以下行为。观察生成函数不能调用任何在生成函数本身定义之前没有定义的方法。

最初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) = x和bar(x::Integer) = x ^ 2会做同样的事情,但它既简单又快速。 baz函数是病态的 请注意,在生成的函数中不应该尝试的操作集是没有限制的,运行时系统目前只能检测到无效操作的一个子集。还有许多其他的操作会在不通知的情况下简单地破坏运行时系统,通常以微妙的方式与坏定义没有明显的联系。因为函数生成器是在推理过程中运行的,它必须尊重该代码的所有限制。

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

缓存本地指针 以任何方式与Core.Compiler的内容或方法进行交互。 观察任何可变状态。

对生成的函数的推理可以在任何时候运行,包括在您的代码试图观察或突变此状态时。 占用任何锁。你调用的C代码可以在内部使用锁,(例如,调用malloc是没有问题的,即使大多数实现在内部需要锁),但不要试图在执行Julia代码时持有或获取任何锁。 调用在生成函数主体之后定义的任何函数。对于增量加载的预编译模块,这个条件是放宽的,允许调用模块中的任何函数。 好了,现在我们对生成函数的工作原理有了更好的理解,让我们用它们来构建一些更高级的(有效的)功能…

一个高级的例子

茱莉亚的基础库有一个内部的sub2ind函数,用来计算一个线性索引到n维数组中,基于n个多线性索引的集合,换句话说,就是计算可以用A[i]来索引到数组A中的索引i,而不是A[x,y,z,…]。一种可能的实现方式如下。

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 @生成块,在这种情况下,生成的实现使用所有的then块,而备用的实现使用所有的else块。

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

在这种风格的定义中,代码生成功能本质上是一种可选的优化。编译器在方便的情况下会使用它,但其他情况下可能会选择使用正常的实现来代替。这种风格是首选,因为它允许编译器做出更多的决定,并以更多的方式编译程序,而且正常代码比代码生成代码更易读。但是,使用哪种实现取决于编译器的实现细节,所以两种实现的行为必须完全相同。