元编程
朱莉娅语言中Lisp最大的遗留问题是它的元编程支持。与Lisp一样,Julia将自己的代码表示为语言本身的数据结构。由于代码是由可以在语言内部创建和操作的对象表示的,因此程序可以转换并生成自己的代码。这允许在不需要额外构建步骤的情况下生成复杂的代码,也允许在抽象语法树级别上运行真正的sql风格宏。相反,像C和c++那样的预处理器“宏”系统,在进行任何实际的解析或解释之前执行文本操作和替换。由于Julia中的所有数据类型和代码都由Julia数据结构表示,因此可以使用强大的反射功能来探索程序的内部结构及其类型,就像其他任何数据一样。
程序表示
每个Julia程序均以字符串开始:
julia> prog = "1 + 1"
"1 + 1"
接下来会发生什么?
下一步是将每个字符串解析为一个被称为表达式的对象,由Julia type Expr表示:
julia> ex1 = Meta.parse(prog)
:(1 + 1)
julia> typeof(ex1)
Expr
Expr对象包含两部分:
- 表示一种表达的符号。符号是一个内嵌的字符串标识符(后面会有更多讨论)。注:一个表达式的标记符号
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中:号有两种含 义:
第一种用途是创建一个标记符,另一种用途是作为表达式构件块内部字符串:
julia> :foo
:foo
julia> typeof(ans)
Symbol
符号构造函数接受任意数量的参数,并通过将它们的字符串表示连接在一起创建一个新的符号:
julia> :foo == Symbol("foo")
true
julia> Symbol("func",10)
:func10
julia> Symbol(:var,'_',"sym")
:var_sym
在表达式的上下文中,符号用于指示对变量的访问;在对表达式求值时,符号被替换为在当前范围内绑定到该符号的值。
注:符号相当于一个表达式的标记,它代表了这个表达式,在对表达式求值时,符号标记被替换为表达式的内容
有时,需要在参数:号周围加上括号,以避免语法分析中的歧义。
julia> :(:)
:(:)
julia> :(::)
:(::)
注:使用:号定义符号(Symbol)时,需要再给:加上圆括号,以避免语法分析中的歧义
julia> :(:)
:(:)
julia> :(::)
:(::)
另附图:
表达式与表达式求值
Quoting 注:此为lisp中quote的定义:quote 是 Common Lisp 中一个特殊的运算符。quote 可以简写为“’”,它的求值规则是什么也不做。
:号的第二个语法用途是在不使用显式Expr构造函数的情况下创建表达式对象。这被称为引用。在:号后面的单个Julia代码语句周围加上一对括号,将根据所包含的代码生成Exr对象。下面是引用算术表达式的简短形式的示例:
julia> ex = :(a+b*c+1)#使用:号定义表达式对象
:(a + b * c + 1)
julia> typeof(ex)
Expr
若要查看此表达式的结构,请尝试ex.head和ex.args,或使用上述转储或Meta.@dump。
请注意,可以使用元解析或直接Exr表单构造等效表达式。
julia> :(a + b*c + 1) ==
Meta.parse("a + b*c + 1") ==
Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true
表达式提供给解析器的通常只有符号、其他表达式和字符串作 为参数,而由Julia代码构造的表达式可以具有任意运行时值,而不以文字形式作为args。在这个特定的例子中,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
注quote end之间的代码表示不进行求值运算
插值
直接构造带值参数的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))
注:此处$相当于将表达式 :(1,2,3)插入到a in后面
使用$在表达式内插有意使人联想到字符串内插和命令内插。表达式内插允许方便、可读的复杂Julia表达式的编程构造。
飞溅插补
注意, 插值语法只允许将一个表达式插入到一个封闭的表达式中。有时,您有一个表达式数组,并需要它们都成为周围表达式的参数。这可以用语法 插 值 语 法 只 允 许 将 一 个 表 达 式 插 入 到 一 个 封 闭 的 表 达 式 中 。 有 时 , 您 有 一 个 表 达 式 数 组 , 并 需 要 它 们 都 成 为 周 围 表 达 式 的 参 数 。 这 可 以 用 语 法 (args…)来完成。例如,下面的代码生成一个函数调用,其中以编程方式确定参数的数量:
ulia> args = [:x, :y, :z];
julia> :(f(1, $(args...)))
:(f(1, x, y, z))
嵌套quote
当然,quote表达式可能包含其他quote表达式。
理解插值在这些情况下是如何工作的可能有点棘手。思考一下这个例子
julia> x = :(1 + 2);
julia> e = quote quote $x end end
quote
#= none:1 =#
$(Expr(:quote, quote
#= none:1 =#
$(Expr(:$, :x))
end))
end
请注意,结果包含expr(:$,:x),这意味还没有对x进行求值计算。
换句话说,$表达式“属于”内部quote表达式,因此它的参数只有在内部引号表达式为:
因此,它的参数是对内部quote求值所得的值:
然而,外部引号表达式能够在内部quote中的$内插值。这是用多个DDs完成的:
julia> e = quote quote $$x end end
quote
#= none:1 =#
$(Expr(:quote, quote
#= none:1 =#
$(Expr(:$, :(1 + 2)))
end))
end
注意:呈现的结果是:(1+2),而不是标记符号(symbol):x;计算这个表达式会得到一个内插的3
julia> eval(e)
quote
#= none:1 =#
3
end
这种行为背后的直觉是,对每个$:一个$计算一次,它的工作方式类似val(:x),给出了x的值,而两个$s的计算值相当于val(:x)。
注:第一个$的作 用使第一个quote-end(外层表达式)可以直接引用$x表达式,此时veal(e)实际上是对直接对$x求值
QuoteNode
对于表达式的标识符 :quote通常使AST来表现一个quote形式
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
正如我们所看到的,这些表达式支持使用$;但是,在某些情况下,需要quote代码而不是执行插值。这种引用还没有语法,但在内部被表示为一个QoteNode类型对象。解析器会提供QuoteNode对象用来描述一个简单的quote组合,如下:
julia> dump(Meta.parse(":x"))
QuoteNode
value: Symbol x
QuoteNode对象也可以用于某些高级元编程任务
求值 效果
给定表达式对象,可能会导致Julia在全局范围内使用val计算(执行)它
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函数,在其全局范围内计算表达式。
传递给val的表达式不限于返回值-它们还可能产生副作用,从而改变封装模块环境的状态。
julia> ex = :(x = 1)
:(x = 1)
julia> x
ERROR: UndefVarError: x not defined
julia> eval(ex)
1
julia> x
1
在这里,表达式对象的求值导致将一个值赋值给全局变量x。
注:eval(ex)的求值结果即是x=1
由于表达式只是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在表达式构造时的值用作表达式中的直接值。因此,计算表达式时的值不再重要:表达式中的值已经是1,与任何可能的值无关。
另一个,在表达式结构中使用符号:b。因此,此时与变量b的值是不相关的-:b只是一个符号,而变量b甚至不需要定义。在表达式求值计算时,符号:b的值转变成声明变量b;
表达式上函数
正如上面所暗示的,Julia的一个非常有用的特性是能够在Julia内部生成和操作Julia代码。我们已经看到了返回EXPR表达式函数的一个例子:解析函数,它接受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
宏命令
宏提供了一种方法,用于将生成的代码包含在程序的最终主体中。宏将参数元组映射到返回的表达式,生成的表达式直接编译,而不需要运行时val调用。宏参数可以包括表达式、文字值和符号(symbols)。
基础
下面是一个非常简单的宏:
julia> macro sayhello()
return :( println("Hello, world!") )
end
@sayhello (macro with 1 method)
宏在Julia的语法中有一个专用描述字符: @ (at-sign),后面跟着宏中声明的唯一名称 NAME … end 代码块,在本例中,编译器将用以下方式替换@say hello的所有实例:
:( println("Hello, world!") )
当@say hello在repl中输入时,表达式将立即执行,因此我们只看到求值结果:
julia> @sayhello()
Hello, world!
注:使用宏函数可以直接对表达式求值,而不需要再手动使用eval()函数求值
现在,考虑一个稍微复杂一些的宏:
julia> macro sayhello(name)
return :( println("Hello, ", $name) )
end
@sayhello (macro with 1 method)
这个宏有一个参数:name;当调用@say hello时,将quote表达式展开,并将参数的值插入表达式中并生成最终的表达式(此过程为宏展开):
julia> @sayhello("human")
Hello, human
我们可以使用宏展开函数macroexpand通过quote表达式来查看宏函数的结构(重要注意:这是调试宏的一个非常有用的工具):
julia> ex = macroexpand(Main, :(@sayhello("human")) )
:((Main.println)("Hello, ", "human"))
julia> typeof(ex)
Expr
我们可以看到,“human”已经被插入到表达式中。
还有一个宏函数@macroexpand ,它可能比macroexpand函数更方便一些:
julia> @macroexpand @sayhello "human"
:((println)("Hello, ", "human"))
Hold up: why macros?
在前一节,我们已经看到这样一个函数f(::Expr…) -> Expr,实际上,宏扩展也是这样一种功能。那么,为什么存在宏呢?
宏是必要的,因为它们在分析代码时执行,因此,宏允许程序员在运行完整程序之前生成并包含定制代码的片段。为了说明两者之间的差异,请考虑以下示例:
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: $(Expr(:quote, :((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, ...)
请注意宏名称前面有区别的@,第一种形式中的参数表达式之间缺少逗号,第二种形式中的@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提供关于宏调用中@ sign 符号的解析器位置的信息(以linenumberode对象的形式),这允许宏包含更好的错误诊断信息,并且通常用于日志记录,字符串解析器宏和文档,例如,以及实现@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提供关于宏调用的扩展上下文的信息(以模块对象的形式)。
这允许宏查找上下文信息,如现有绑定,或者将该值作为运行时函数调用的额外参数,在当前模块中进行自映射。
构建高级的宏
以下是Julia @Asser t宏的简化定义:
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)被替换到占位符 $ex位置,而字符串的值(:(1=1.0)被替换到到断言消息参数,这样构造的整个表达式被放置在发生@Assert宏调用的语法树中的位置,然后,在执行时,如果测试表达式的计算结果为true,则不会返回任何内容,而如果测试为false,则会引发一个错误,指示断言的表达式为false。注意,不可能将它写成函数,因为只有条件的值是可用的,并且不可能在错误消息中显示计算它的表达式。
julia中的@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)应等于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宏很好地替换拼接到quote的表达式来处理宏体内表达式的操作。
Hygiene(宏卫生问题)
在更复杂的宏中出现的一个问题是卫生问题。简而言之,宏必须确保它们在返回的表达式中引入的变量不会意外地与它们展开的代码中的现有变量发生冲突。相反,作为参数传递到宏中的表达式通常需要在周围代码的上下文中计算,与现有变量交互和修改。另一个问题是宏可能被调用在与定义宏不同的模块中。在这种情况下,我们需要确保所有全局变量都被解析为正确的模块。与文本宏展开(如c)相比,Julia已经有了一个主要的优势,因为它只需要考虑返回的表达式。所有其他变量(如上面@Assert中的msg)都遵循正常的作用域块行为。
为了演示这些问题,让我们考虑编写一个@Time宏,该宏以表达式作为其参数,记录时间,计算表达式,再次记录时间,打印前后时间之间的差异,然后将表达式的值作为其最终值。宏可能如下所示:
macro time(ex)
return quote
local t0 = time()
local val = $ex
local t1 = time()
println("elapsed time: ", t1-t0, " seconds")
val
end
end
在这里,我们希望t0、t1和val是私有的临时变量,我们希望有time函数引用Julia base中的time函数,而不是用户可能拥有的任何时间变量(println也是如此)。想象一下,如果用户表达式ex也包含对一个名为t0的变量的赋值,或者定义了它自己的时间变量,那么可能会出现什么问题。我们可能会犯错或神秘的错误行为。
Julia的宏展开器通过以下方式解决了这些问题:首先,宏结果中的变量被划分为局部变量或全局变量。如果将变量赋值给(而不是声明为全局的)、声明为局部变量或用作函数参数名,则该变量被视为本地变量。否则,它被认为是全局的。局部变量被重命名为唯一的(使用gensym函数,它生成新的符号),全局变量在宏定义环境中被解析。因此,上述两项关切都得到了处理;宏的局部变量不会与任何用户变量冲突,time函数和println函数将引用Julia基定义。
然而,有一个问题仍然存在。请考虑使用以下宏:
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或其他类似的表达式中。方面的一些示例包括:简单返回 Task(() -> $body)和简单返回@eval expr。
为了演示,我们可以将上面的@time示例重写为:
macro time(expr)
return :(timeit(() -> $(esc(expr))))
end
function timeit(f)
t0 = time()
val = f()
t1 = time()
println("elapsed time: ", t1-t0, " seconds")
return val
end
然而,我们这么做并不是出于一个很好的理由:将expr封装在一个新的作用域块(匿名函数)中也会稍微改变表达式的含义(它中任何变量的作用域),而我们希望@time能够在对包装代码的影响最小的情况下使用。
宏与调度
未完待续