元编程
Julia深受Lisp的影响,并且为一些内部解析提供了Lisp变体解释器(FemtoLisp)。因此,它很自然地包含了元编程,这是Lisp衍生函数中最灵活的特性之一。本章,我们将介绍Julia是如何实现元编程的。
“元”这个词,是来自希腊语中表示“在……之间、在……之后、超过……”的前缀词 meta,具有超越、高阶等意思。从这个意思引申出来,在单词前面加上 meta,表示对自身的描述。例如,描述数据所具有的结构的数据,也就是关于数据本身的数据,被称为元数据(Metadata)。再举个比较特别的例子,小说中的角色如果知道自己所身处的故事是虚构的,这样的小说就被称为元小说(Metafiction);再比如,如果参与者知道自己所处的空间(或宇宙)是虚拟的,则这样的场景就被称为元宇宙(Metaverse)。不知各位有没有过这种体验,就是在做梦的时候知道自己在做梦?如果有过这种体验,相信你会对“元”这个概念有深刻的体会。
综上所述,我们可以推论,所谓元编程,就是“用程序来编写程序”的意思。
背景知识
元编程本质上是利用数据生成可以在应用程序自身内执行的代码。有些语言会在编译过程中执行这种代码生成;有些则将其视为执行的一部分。虽然代码生成和注入到执行单元的阶段可能会有所不同(这取决于语言的内部架构),但所有语言都会在对其进行后期处理之前以基语言生成代码。下面是一个来自C/C++的例子:
#define twice(x) 2*(x)
int main()
{
int y = twice(2);
}
通过C/C++预处理器运行它,你将得到这样的输出。
int main()
{
int y = 2*(2);
}
可以看到,预处理器将twice
宏替换为代码中的值。一旦预处理器运行,新生成的代码将在c编译器中进行编译。让我们看看c++语言中实现相同功能的模板元编程。
template <typename T>
T twice(T x){
return 2*x
}
int main()
{
int y = twice(2);
}
运行预处理器不会实质上改变代码。因此,有人可能会认为这不是元编程。但是,它也是元编程的一种形式。在编译过程中,twice<T>
函数的模板类型将被解析,并生成twice<int>()
函数。Julia支持基于模板的元编程,我们已经在关于函数和方法的章节中看到了这一点。Julia能够转换任何文本字符串,并限定其为可以在程序中执行的代码。虽然对一些人来说,这听起来可能令人兴奋,但它也会引发对安全后果的担忧。这个概念与执行宏的类Lisp语言非常相似。
抽象语法树(AST)
抽象语法树(AST)是计算机科学中代码表达式的一种语法结构。这种结构形式使机器更容易理解代码,并消除了操作符优先级或使用括号修改优先级的情况。在Julia中,AST代码片段是一个有效的对象。因此,你可以灵活地使用一段代码生成它的AST,并将AST作为Julia对象进行操作,以创建可以执行的有趣的派生AST。这是Julia的元编程体系结构的基础。简单地说,你可以生成任何有效的代码字符串,并通过有效地操作在程序中执行它。让我们在下面的例子中看看其中的一些操作:
julia> s = "1+2"
"1+2"
julia> ex = Meta.parse(s)
:(1 + 2)
julia> typeof(ex)
Expr
julia> dump(ex)
Expr head: Symbol call args: Array{Any}((3,)) 1: Symbol + 2: Int64 1 3: Int64 2
我们获取了一个可以是Julia表达式的有效字符串。我们使用Julia解析器对其进行解析,并以一个限定字符串格式返回输出。限定字符串是一种特殊的字符串,它确保了具有相同字面值的对象在内存中只保存一个副本,如下例所示:
julia> a = "abc"
"abc"
julia> b = "abc"
"abc"
julia> c = :abc
:abc
julia> d = :abc
:abc
julia> pointer_from_objref(a)
Ptr{Nothing} @0x00007f50ea035bd0
julia> pointer_from_objref(b)
Ptr{Nothing} @0x00007f50ea035c30
julia> pointer_from_objref(c)
Ptr{Nothing} @0x00007f50a052e908
julia> pointer_from_objref(d)
Ptr{Nothing} @0x00007f50a052e908
Julia元编程的限定字符串格式为Expr
类型。dump
方法给出表达式的AST。从AST中可以识别出以下内容:
ex
的类型为Expr
。ex.head
是调用符号。ex.args
有三个元素。args[1]
是符号+
,通过调用触发。args[2]
和args[3]
是函数调用的参数。
我们来计算这个表达式,其结果应该是3。
julia> eval(ex)
3
让我们修改限定表达式ex
,将ex.args[1]
从+
改为-
。
julia> ex.args[1] = :(-);
julia> dump(ex)
Expr head: Symbol call args: Array{Any}((3,)) 1: Symbol - 2: Int64 1 3: Int64 2
julia> eval(ex)
-1
对调用序列中参数的简单修改可以更改要求值的调用函数。
符号和限定字符串
在Julia AST中,符号对象被用作函数名和终结符。可以在标记的前面输入字符:
来快速定义符号。符号构造函数也是可用的。它将把所有的参数连接起来形成一个符号。
julia> Symbol("A")
:A
julia> Symbol("A", "_", 5, "32")
:A_532
julia> :A_123
:A_123
julia> :(==)
:(==)
当使用冒号定义符号时,一些字母可能需要消除歧义,比如在这种情况下的==
操作符。与使用Expr
构造函数或像前一节中那样使用Meta.parse
解析字符串相比,你可以使用冒号字符(:
)来定义表达式或限定字符串。
julia> ex = :(z = 1 + 2)
:(z = 1 + 2)
julia> typeof(ex)
Expr
这里生成的Expr
有两个部分的AST。
第一部分为变量赋值。
调用运算符
+
,其结果是赋值的右值。
julia> dump(ex)
Expr head: Symbol = args: Array{Any}((2,)) 1: Symbol z 2: Expr head: Symbol call args: Array{Any}((3,)) 1: Symbol + 2: Int64 1 3: Int64 2
julia> eval(ex)
3
julia> z
3
赋值表达式向程序状态引入一个新的赋值变量z
。因此,对表达式和求值的操作可以修改程序状态。在对它们进行操作时,必须注意变量的范围。
行内计算
虽然可以任意设计表达式,但除非包含预求值表达式,否则它并不是很有用。例如:
julia> x = 5;
julia> ex = :($x + 1)
:(5 + 1)
你也可以在行内求值中引入计算表达式。例如:
julia> ex = :($(x*x)+1)
:(25 + 1)
julia> y = eval(ex)
26
这种技术在Julia编程中也被称为插值。
多行表达式
到目前为止,我们已经看到了作为限定字符串的简单表达式的AST。然而,现实生活中的函数和表达可能非常复杂。人们自然会产生对于通过编程操作表达式是否存在一些特定限制的疑问。
julia> ex = quote x = 1 y = 2 x + y end
quote #= REPL[1]:2 =# x = 1 #= REPL[1]:3 =# y = 2 #= REPL[1]:4 =# x + y end
julia> dump(ex)
Expr head: Symbol block args: Array{Any}((6,)) 1: LineNumberNode line: Int64 2 file: Symbol REPL[1] 2: Expr head: Symbol = args: Array{Any}((2,)) 1: Symbol x 2: Int64 1 3: LineNumberNode line: Int64 3 file: Symbol REPL[1] 4: Expr head: Symbol = args: Array{Any}((2,)) 1: Symbol y 2: Int64 2 5: LineNumberNode line: Int64 4 file: Symbol REPL[1] 6: Expr head: Symbol call args: Array{Any}((3,)) 1: Symbol + 2: Symbol x 3: Symbol y
多行表达式包含在quote...end
之间。正如你所看到的,为了行调试目的,添加了行号表达式来跟踪行号。该表达式的转储会显示AST的更详细的视图。它有以下特性:
它以head属性的类型块开始。
args
数组包含行节点和后续表达式部分。行包含一个行号部分和一个设置表达式内容的文件部分。
让我们修改第2行中的赋值,从x = 1
变为z = 1
。
julia> ex.args[2].args[1] = :z
:z
julia> ex
quote #= REPL[1]:2 =# z = 1 #= REPL[1]:3 =# y = 2 #= REPL[1]:4 =# x + y end
julia> eval(ex)
7
julia> z
1
新变量z
的值为1,x
的值是从上一节中获得的,为5。因此,ex
表达式的计算结果为7。
嵌套引用和插值
让我们看看一些更复杂的引用表达式,其中一个quote...end
包含在另一个引用表达式中。
julia> e = :(1 + 1);
julia> eval(:e)
:(1 + 1)
julia> eval(e)
2
在前面的例子中,对符号:e
进行eval
运算,计算的结果是变量e
的内容。而对变量e
进行eval
运算的结果实际上是对表达式求值,注意这两者间的差异。
我们将在嵌套引用中重新研究的类似概念。
julia> e = :(1+2)
:(1 + 2)
julia> ex = quote quote $e end end
quote #= REPL[2]:2 =# $(Expr(:quote, quote #= REPL[2]:3 =# $(Expr(:$, :e)) end)) end
julia> eval(ex)
quote #= REPL[2]:3 =# 1 + 2 end
在前面的例子中,$e
被绑定到内引用,等价于eval(:e)
。因此,表达式1+2
出现在quote...end
之间。
julia> ex = quote quote $$e end end
quote #= REPL[1]:2 =# $(Expr(:quote, quote #= REPL[1]:3 =# $(Expr(:$, :(1 + 2))) end)) end
julia> eval(ex)
quote #= REPL[1]:3 =# 3 end
当使用$$e
时,计算eval(eval(:e))
,因此计算出来的值3被放在quote...end
之间。
函数
下面的函数可以从输入参数生成并返回一个限制表达式。
julia> function math_exp(op, p1, p2) p1f, p2f = map(x->x isa Number ? 2x : error("Parameters have to be numbers"), (p1, p2)) return Expr(:call, op, p1f, p2f) end
math_exp (generic function with 1 method)
julia> ex = math_exp(:+, 2, 3)
:(4 + 6)
julia> eval(ex)
10
该函数返回一个Julia表达式。你需要一个额外的eval
调用来求值。
宏
函数可以返回一个表达式,但必须显式地调用eval
来计算表达式的值。宏返回的表达式会自动计算,如下所示:
julia> macro sayhello() return :(println("Hello, World!")) end
@sayhello (macro with 1 method)
julia> @sayhello()
Hello, World!
julia> macro sayhello(name) return :(println("Hello, ", $name, "!" )) end
@sayhello (macro with 2 methods)
julia> @sayhello("John")
Hello, John!
调用约定
宏可以采用下面任意一种调用形式:
@mymacro(param1, param2, ..., paramN)
@mymacro param1 param2 ... paramN
因此,这两个@sayhello
宏可以用如下方式调用:
julia> @sayhello
Hello, World!
julia> @sayhello "John"
Hello, John!
让我们添加一个更复杂的表达式。
julia> @sayhello begin 1 + 3 end
Hello, 4!
与方法类似,宏同样遵循多分派架构。引入一个新宏@sayhello(x::Int)
可以通过下面的方式影响行为:
julia> macro sayhello(x::Int) println("Calling Int ", x) return :(println("Hello Int, ", $x)) end
@sayhello (macro with 3 methods)
julia> @sayhello 21
Calling Int 21 Hello Int, 21
julia> x = 21;
julia> @sayhello x
Hello, 21!
当使用Int
字面值作为形参时,会调用宏@sayhello(::Int)
。但是,当变量x
作为参数时,会调用@sayhello(x)
。宏将AST对象作为输入参数。因此,调用@sayhello x
将被解释为sayhello
连同符号x
作为参数,而不是21作为参数被调用。下面的例子将进一步阐明这个论述:
julia> macro intype(x) println(typeof(x)) end
@intype (macro with 1 method)
julia> @intype begin x = 5 end
Expr
julia> @intype x
Symbol
julia> @intype 21
Int64
julia> @intype "John"
String
自定义字符串字面值
自定义字符串字面值本质上是用来代替构造函数的便捷宏。我们已经在字符串那一章的正则表达式中看到过这些宏的例子。Julia中正则表达式宏定义如下所示:
macro r_str(p)
Regex(p)
end
可以调用如下所示的正则表达式匹配表达式。
julia> m = match(r"a.a", "abracadabra")
RegexMatch("aca")
表达式r"..."
相当于调用宏@r_str()
。让我们为大写字符串创建一个自定义表达式。
julia> struct CapsString s::String CapsString(s::String)=new(uppercase(s)) end
julia> macro C_str(s) CapsString(s) end
@C_str (macro with 1 method)
julia> C"This is upper case string"
Main.CapsString("THIS IS UPPER CASE STRING")
生成函数
Julia使用多分派体系结构调用函数和方法。然而,有时人们可能希望创建比该语言支持的更为复杂的分派模型并自行编译。生成函数在这些场景中很有帮助。让我们考虑一个简单的分派场景。
julia> f(x::Integer) = x^2;
julia> f(x) = x;
julia> f(4)
16
julia> f("John")
"John"
在前面的例子中,使用标准分派规则,f(4)
应该调用f(::Integer)
,f(“John”)
调用f(x)
。如果必须建立一个自定义的分派规则,你可以使用@generated
宏来创建一个函数,如下所示:
julia> @generated function genf(x) if x <: Integer return :(x^2) else return :x end end
genf (generic function with 1 method)
在前面的函数中,生成函数只使用x
的类型,x
的值被忽略。该函数根据自定义分派过程返回函数所期望的表达式。
julia> genf(4)
16
julia> genf("John")
"John"
当这些方法被调用时,带有整形参数的函数将被编译和缓存。后续调用如genf(3)
会触发编译后的表达式,这样可以更高效。关于生成宏的更详细示例,建议参考Julia文档。
函数的缓存生成是不确定的。因此,与生成器一起使用的函数必须没有副作用。它们也不应该修改全局变量。
生成函数只用于难以消除歧义的复杂分派规则。对于标准场景,使用语言默认的分派规则总是更高效。
常用宏
Julia有许多内置宏,用于简化某些任务。在前几章中,我们已经介绍过@view
、@goto
、@label
等。我们将在本节介绍另外一些。
源位置
每个宏都可以访问变量__source__
(该变量可以访问文件名、行号)和__module__
(给出宏定义所在模块的名称)。
julia> macro test() println(__module__) println(__source__) end
@test (macro with 1 method)
julia> @test
Main #= REPL[2]:1 =#
它们被用来开发一些实用的位置宏,如@__FILE__
,@__LINE__
和@__DIR__
。
julia> function macro_usage() println("Dir: ", @__DIR__, " file: ", @__FILE__, " Line No.: ", @__LINE__) end
macro_usage (generic function with 1 method)
julia> macro_usage()
Dir: /home/lf/Example/docs/build file: REPL[1] Line No.: 2
当代码在REPL或IJulia中使用时,@__FILE__
宏不会返回一个文件名,它返回命令标识符。
eval
当要生成大量样板代码时,可以高效地使用@eval
。
julia> struct MyNumber v::Float64 end
假设我们要在这个数字上定义所有的数学方法,比如sin
,cos
,tan
。代码可能像这样:
Base.sin(m::MyNumber) = Base.sin(m.v)
Base.cos(m::MyNumber) = Base.cos(m.v)
Base.tan(m::MyNumber) = Base.tan(m.v)
下面的表达式可以避免重复代码:
julia> for op in [:sin, :cos, :tan] eval(quote Base.$op(m::MyNumber)=Base.$op(m.v) end) end
eval(quote...end)
是许多Julia应用程序中常用的表达式。宏@eval
实现相同的功能,表达式如下所示:
julia> for op in [:sin, :cos, :tan] @eval Base.$op(m::MyNumber)=Base.$op(m.v) end
assert
@assert
是一个宏,它验证表达式的真值,如果表达式的计算结果为false
,则抛出AssertionError
。其他语言如C/C++也有这样的宏。在某些语言中,断言验证仅是代码在调试模式下执行,而在优化发布模式时忽略。Julia也建议只在调试操作中使用assert
宏。因此,它不应该被用作程序逻辑。assert
最简单的形式如下所示:
julia> macro myassert(ex, msg) return :($(ex) ? nothing : throw(AssertionError($msg))) end
@myassert (macro with 1 method)
julia> @myassert 1 == 0 "1 is not same as 0"
ERROR: AssertionError: 1 is not same as 0
实际的assert
宏没有限制可以传递给它的参数的数量,但是只利用第一个消息作为有效消息。如果没有消息,则利用ex
作为消息。
julia> macro myassert(ex, msgs...) msg = isempty(msgs) ? ex : msgs[1] msg = msg isa AbstractString ? String(msg) : string(msg) return :($(ex) ? nothing : throw(AssertionError($msg))) end
@myassert (macro with 1 method)
julia> @myassert 1 == 0
ERROR: AssertionError: 1 == 0
最后的代码与前面的稍有不同,因为它解决了一些简单示例代码没有解决的错误条件。assert
被定义为宏是合适的,因为在Julia中ex
参数可以是任何表达式。
time
@time
宏用于计算执行代码块的运行时间和分配。
julia> @time begin sleep(0.3) 1+1 end
0.301362 seconds (5 allocations: 144 bytes) 2
@time
宏的一部分实现如下代码:
julia> macro mytime(ex) return quote local elapsedtime = time_ns() local val = $(ex) elapsedtime = time_ns() - elapsedtime println("Elapsed time: ", elapsedtime/1e9) val end end
@mytime (macro with 1 method)
val
在elapsedtime
之前计算。因此,两者都需要存储在一个局部变量中。由于返回值是用引号括起来的表达式,如果变量val
和elapsedtime
没有显式地定义为局部变量,那么如果有全局作用域的变量val
和elapsedtime
,它们将被覆盖。它只对从宏返回的Expr
起作用,不会影响宏中使用的变量,如前面例子中的msg
。