Julia/元编程介绍
元编程是指编写 Julia 代码来处理和修改 Julia 代码。使用元编程工具,你可以编写 Julia 代码来修改源文件中的其他部分,甚至控制修改后的代码何时以及是否运行。
在 Julia 中,原始源代码的执行分为两个阶段。(实际上,阶段不止这两个,但现在我们只关注这两个。)
阶段 1 是解析你的原始 Julia 代码——将其转换为适合评估的形式。你应该熟悉这个阶段,因为这就是所有语法错误被发现的时候......这个阶段的结果是抽象语法树或 AST(抽象语法树),一个包含所有代码的结构,但格式比通常使用的人类友好语法更容易操作。
阶段 2 是执行解析后的代码。通常,当你将代码输入 REPL 并按下回车键,或者从命令行运行 Julia 文件时,你不会注意到这两个阶段,因为它们发生得太快。然而,使用 Julia 的元编程功能,你可以在代码被解析之后但被评估之前访问它。
这让你可以做一些你通常无法做的事情。例如,你可以将简单的表达式转换为更复杂的表达式,或者在代码运行之前检查它并更改它以使其运行得更快。任何你使用这些元编程工具拦截和修改的代码最终都会以通常的方式被评估,并像普通的 Julia 代码一样快速运行。
你可能已经使用过 Julia 中的两个现有的元编程示例:
- @time
宏
julia> @time [sin(cos(i)) for i in 1:100000]; 0.102967 seconds (208.65 k allocations: 9.838 MiB)
@time
宏在代码开头插入“启动秒表”命令,并在末尾添加一些代码来“停止秒表”,并计算经过的时间和内存使用情况。然后修改后的代码被传递以进行评估。
- @which
宏
julia> @which 2 + 2 +(x::T, y::T) where T<:Union{Int128, Int16, Int32, Int64, Int8, UInt128, UInt16, UInt32, UInt64, UInt8} in Base at int.jl:53
这个宏根本不允许表达式 2 + 2
被评估。相反,它会报告将使用哪个方法来处理这些特定的参数。它还会告诉你包含该方法定义的源文件以及行号。
元编程的其他用途包括通过编写生成较长代码块的短代码段来自动化繁琐的编码任务,以及通过生成你可能不想手动编写的更快的代码来提高“标准”代码的性能。
为了使元编程成为可能,Julia 必须有一种方法来存储未评估但已解析的表达式,以便在解析阶段完成之后立即执行。这是 ':'(冒号)前缀运算符
julia> x = 3 3 julia> :x :x
对 Julia 而言,:x
是一个未评估或引用的符号。
(如果你不熟悉计算机编程中引用的符号的使用,想想在写作中如何使用引号来区分普通用法和特殊用法。例如,在句子中
'铜'包含六个字母。
引号表示“铜”这个词不是指金属,而是指这个词本身。同样,在 :x
中,符号前的冒号是为了让你和 Julia 认为 'x' 是一个未评估的符号,而不是值 3。)
要引用整个表达式而不是单个符号,请以冒号开头,然后将 Julia 表达式括在括号中
julia> :(2 + 2) :(2 + 2)
:( )
结构有另一种形式,它使用 quote
... end
关键字来括起和引用一个表达式
quote
2 + 2
end
这将返回
quote #= REPL[123]:2 =# 2 + 2 end
而这个表达式
expression = quote
for i = 1:10
println(i)
end
end
将返回
quote #= REPL[124]:2 =# for i = 1:10 #= REPL[124]:3 =# println(i) end end
expression
对象的类型为 Expr
julia> typeof(expression)
Expr
它已解析、准备就绪,可以执行。
还有一个用于评估未评估表达式的函数。它被称为 eval()
julia> eval(:x)
3
julia> eval(:(2 + 2))
4
julia> eval(expression)
1
2
3
4
5
6
7
8
9
10
使用这些工具,可以创建任何表达式并存储它而不必对其进行评估
e = :(
for i in 1:10
println(i)
end
)
返回
:(for i = 1:10 # line 2: println(i) end)
然后稍后进行回忆和评估
julia> eval(e) 1 2 3 4 5 6 7 8 9 10
更有用的是,可以在表达式被评估之前修改其内容。
一旦你在未评估的表达式中获得了 Julia 代码,而不是将其作为字符串中的文本片段,你就可以对其进行操作。
这是一个表达式
P = quote
a = 2
b = 3
c = 4
d = 5
e = sum([a,b,c,d])
end
这将返回
quote #= REPL[125]:2 =# a = 2 #= REPL[125]:3 =# b = 3 #= REPL[125]:4 =# c = 4 #= REPL[125]:5 =# d = 5 #= REPL[125]:6 =# e = sum([a, b, c, d]) end
注意已为引用的表达式中的每一行添加的帮助行号。(每行的标签都添加到上一行的末尾。)
我们可以使用 fieldnames()
函数来查看这个表达式内部的内容
julia> fieldnames(typeof(P)) (:head, :args, :typ)
head
字段是 :block
。args
字段是一个数组,包含表达式(包括注释)。我们可以使用 Julia 的常见技术来检查它们。例如,第二个子表达式是什么
julia> P.args[2] :(a = 2)
将它们打印出来
for (n, expr) in enumerate(P.args)
println(n, ": ", expr)
end
1: #= REPL[125]:2 =# 2: a = 2 3: #= REPL[125]:3 =# 4: b = 3 5: #= REPL[125]:4 =# 6: c = 4 7: #= REPL[125]:5 =# 8: d = 5 9: #= REPL[125]:6 =# 10: e = sum([a, b, c, d])
如你所见,表达式 P
包含许多子表达式。我们可以很容易地修改这个表达式;例如,我们可以更改表达式的最后一行以使用 prod()
而不是 sum()
,这样,当 P 被评估时,它将返回变量的乘积而不是它们的总和。
julia> eval(P) 14 julia> P.args[end] = quote prod([a,b,c,d]) end quote #= REPL[133]:1 =# prod([a, b, c, d]) end julia> eval(P) 120
或者,你可以通过进入表达式来直接定位 sum()
符号
julia> P.args[end].args[end].args[1] :sum julia> P.args[end].args[end].args[1] = :prod :prod julia> eval(P) 120
这种在解析代码后表示代码的方式被称为 AST(抽象语法树)。它是一个嵌套的分层结构,旨在让你和 Julia 都能轻松地处理和修改代码。
非常有用的 dump
函数可以让你轻松地可视化表达式的分层性质。例如,表达式 :(1 * sin(pi/2))
以这种方式表示
julia> dump(:(1 * sin(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 sin 2: Expr head: Symbol call args: Array{Any}((3,)) 1: Symbol / 2: Symbol pi 3: Int64 2 typ: Any typ: Any typ: Any
你可以看到 AST 完全由 Expr 和原子(例如符号和数字)组成。
在某种程度上,字符串和表达式是相似的——它们可能包含的任何 Julia 代码通常不会被评估,但你可以使用插值来评估其中的一部分代码。我们已经遇到了字符串插值运算符,即美元符号($)。当它用在字符串中时,可能还会使用括号将表达式括起来,这将评估 Julia 代码并将结果值插入到字符串中的那个位置
julia> "the sine of 1 is $(sin(1))" "the sine of 1 is 0.8414709848078965"
同样,你可以使用美元符号将执行的 Julia 代码的结果插值到表达式中(否则表达式不会被评估)
julia> quote s = $(sin(1) + cos(1)); end
quote # none, line 1: s = 1.3817732906760363 end
即使这是一个引用的表达式,因此不会被评估,sin(1) + cos(1)
的值也会被计算并插入表达式中,替换原始代码。此操作称为“拼接”。
与字符串插值一样,只有当你想要包含表达式的值时才需要括号——单个符号可以使用单个美元符号插值。
一旦你学会了如何创建和处理未评估的 Julia 表达式,你就会想知道如何修改它们。一个宏
是一种方法,它可以在给定一个未评估的输入表达式的情况下生成一个新的输出表达式。当你的 Julia 程序运行时,它首先解析并评估宏,然后宏生成的处理后的代码最终像普通表达式一样被评估。
以下是定义一个简单宏的方法,该宏会打印出传递给它的内容,然后将表达式返回给调用环境(此处为 REPL)。语法与你定义函数的方式非常相似
macro p(n)
if typeof(n) == Expr
println(n.args)
end
return n
end
你通过在名称前面加上@
前缀来运行宏。这个宏期望一个参数。你提供的是未评估的 Julia 代码,不必像函数参数那样用括号括起来。
首先,让我们用一个数字参数来调用它
julia> @p 3 3
数字不是表达式,因此宏中的if
条件不适用。宏所做的只是返回n
。但是,如果你传递一个表达式,宏中的代码就可以在表达式被评估之前使用.args
字段检查和/或处理表达式的內容。
julia> @p 3 + 4 - 5 * 6 / 7 % 8 Any[:-,:(3 + 4),:(((5 * 6) / 7) % 8)] 2.7142857142857144
在这种情况下,if
条件被触发,传入表达式的参数以未评估的形式打印出来。因此你可以看到参数作为一个表达式数组,它们在被 Julia 解析后但未被评估之前。你还可以看到算术运算符的不同优先级是如何在解析操作中被考虑的。注意顶层运算符和子表达式是如何用冒号 (:
) 引用。
还要注意,宏p
返回了参数,然后该参数被评估,因此得到了2.7142857142857144
。但它不必这样做——它可以返回一个引用的表达式。
例如,内置的@time
宏返回一个引用的表达式,而不是使用eval()
来评估宏中的表达式。由@time
返回的引用表达式在宏完成其工作后,在调用上下文中被评估。以下是定义
macro time(ex)
quote
local t0 = time()
local val = $(esc(ex))
local t1 = time()
println("elapsed time: ", t1-t0, " seconds")
val
end
end
注意$(esc(ex))
表达式。这就是你“转义”要计时代码的方式,代码在ex
中,这样它不会在宏中被评估,而是保持完整,直到整个引用表达式返回到调用上下文并在那里执行。如果只是说$ex
,那么表达式将被内插并立即评估。
如果你要向宏传递多行表达式,请使用begin
... end
形式
@p begin
2 + 2 - 3
end
Any[:( # none, line 2:),:((2 + 2) - 3)] 1
(你也可以用括号调用宏,就像调用函数一样,使用括号括住参数
julia> @p(2 + 3 + 4 - 5) Any[:-,:(2 + 3 + 4),5] 4
这将允许你定义接受多个表达式作为参数的宏。)
有一个eval()
函数,还有一个@eval
宏。你可能想知道这两个有什么区别?
julia> ex = :(2 + 2) :(2 + 2) julia> eval(ex) 4 julia> @eval ex :(2 + 2)
函数版本 (eval()
) 会扩展表达式并对其进行评估。宏版本不会自动扩展你提供给它的表达式,但你可以使用内插语法来评估表达式并将它传递给宏。
julia> @eval $(ex) 4
换句话说
julia> @eval $(ex) == eval(ex) true
以下是一个你可能想要使用一些自动化来创建一些变量的示例。我们将创建前十个平方和十个立方,首先使用eval()
for i in 1:10
symbolname = Symbol("var_squares_$(i)")
eval(quote $symbolname = $(i^2) end)
end
这会创建许多名为var_squares_n
的变量,例如
julia> var_squares_5 25
然后使用@eval
for i in 1:10
symbolname = Symbol("var_cubes_$(i)")
@eval $symbolname = $(i^3)
end
这类似地创建了许多名为var_cubes_n
的变量,例如
julia> var_cubes_5 125
一旦你感到自信,你可能更喜欢这样写
julia> [@eval $(Symbol("var_squares_$(i)")) = ($i^2) for i in 1:10]
当你使用宏时,你必须注意作用域问题。在前面的示例中,$(esc(ex))
语法用于防止表达式在错误的上下文中被评估。以下是一个另一个人为的示例来说明这一点。
macro f(x)
quote
s = 4
(s, $(esc(s)))
end
end
这个宏声明了一个变量s
,并返回一个包含s
和s
的转义版本的引用表达式。
现在,在宏之外,声明一个符号s
julia> s = 0
运行宏
julia> @f 2 (4,0)
你可以看到宏返回了符号s
的不同值:第一个是宏上下文中的值,为 4,第二个是s
的转义版本,它在调用上下文中被评估,其中s
的值为 0。从某种意义上说,esc()
在s
值无损地通过宏时对其进行了保护。对于更现实的@time
示例,重要的是你要计时的表达式不会以任何方式被宏修改。
要查看宏在最终执行之前扩展成什么样子,请使用macroexpand()
函数。它期望一个包含一个或多个宏调用的引用表达式,这些宏调用随后将扩展成适当的 Julia 代码,这样你就可以看到宏被调用时会做什么。
julia> macroexpand(Main, quote @p 3 + 4 - 5 * 6 / 7 % 8 end) Any[:-,:(3 + 4),:(((5 * 6) / 7) % 8)] quote #= REPL[158]:1 =# (3 + 4) - ((5 * 6) / 7) % 8 end
(#none, line 1:
是一个文件名和行号引用,它在源文件中使用比在使用 REPL 时更有用。)
以下是一个另一个示例。这个宏向语言添加了一个dotimes
构造。
macro dotimes(n, body)
quote
for i = 1:$(esc(n))
$(esc(body))
end
end
end
它使用方法如下
julia> @dotimes 3 println("hi there") hi there hi there hi there
或者,不太可能,像这样
julia> @dotimes 3 begin for i in 4:6 println("i is $i") end end
i is 4 i is 5 i is 6 i is 4 i is 5 i is 6 i is 4 i is 5 i is 6
如果你对它使用macroexpand()
,你可以看到符号名称发生了什么变化
macroexpand(Main, # we're working in the Main module
quote
@dotimes 3 begin
for i in 4:6
println("i is $i")
end
end
end
)
输出如下
quote
#= REPL[160]:3 =#
begin
#= REPL[159]:3 =#
for #101#i = 1:3
#= REPL[159]:4 =#
begin
#= REPL[160]:4 =#
for i = 4:6
#= REPL[160]:5 =#
println("i is $(i)")
end
end
end
end
end
宏本身的局部变量i
被重命名为#101#i
,以避免与我们传递给它的代码中的原始i
冲突。
以下是定义一个宏的方法,该宏更有可能在你的代码中发挥作用。
Julia 没有直到条件 ... 做一些事情 ... 结束语句。也许你想输入类似的东西
until x > 100
println(x)
end
你将能够使用新的until
宏编写你的代码,像这样
until <condition>
<block_of_stuff>
end
但是,在幕后,工作将由具有以下结构的实际代码完成
while true
<block_of_stuff>
if <condition>
break
end
end
这形成了新宏的主体,它将被包含在一个quote
... end
块中,像这样,以便在评估时执行,但不是之前
quote
while true
<block_of_stuff>
if <condition>
break
end
end
end
因此,几乎完成的宏代码是这样的
macro until(<condition>, <block_of_stuff>)
quote
while true
<block_of_stuff>
if <condition>
break
end
end
end
end
剩下的唯一工作是弄清楚如何传递<block_of_stuff>
和<condition>
部分的代码。回想一下,$(esc(...))
允许代码以“转义”(即未评估)的方式传递。我们将保护条件和代码块免于在宏代码运行之前被评估。
因此,最终的宏定义是这样的
macro until(condition, block)
quote
while true
$(esc(block))
if $(esc(condition))
break
end
end
end
end
新宏使用方法如下
julia> i = 0 0 julia> @until i == 10 begin global i += 1 println(i) end
1 2 3 4 5 6 7 8 9 10
或者
julia> x = 5 5 julia> @until x < 1 (println(x); global x -= 1) 5 4 3 2 1
如果你想更完整地了解编译过程(而不是这里提供的解释),请访问下面“进一步阅读”部分中显示的链接。
Julia 执行多个“传递”将你的代码转换为本机汇编代码。如上所述,第一个传递解析 Julia 代码并构建适合宏操作的“表面语法”AST。第二个传递降低这个高级 AST 到一个中间表示,它被类型推断和代码生成使用。在这个中间 AST 格式中,所有宏都被扩展,所有控制流都被转换为显式分支和语句序列。在此阶段,Julia 编译器尝试确定所有变量的类型,以便选择通用函数(可以有多个方法)的最合适方法。
- https://docs.julia-lang.cn/en/v1/devdocs/reflection/ 关于官方 Julia 文档中抽象语法树的更多信息
- http://blog.leahhanson.us/post/julia/julia-introspects.html Julia Introspects,Leah Hanson 2013 年撰写的一篇有用的文章