Julia 入门教程 Julia 元编程

2024-02-25 开发教程 Julia 入门教程 匿名 4

元编程

类似 Lisp ,Julia 自身的代码也是语言本身的数据结构。由于代码是由这门语言本身所构造和处理的对象所表示的,因此程序也可以转换并生成自身语言的代码。元编程的另一个功能是反射,它可以在程序运行时动态展现程序本身的特性。

表达式和求值

Julia 代码表示为由 Julia 的 Expr类型的数据结构而构成的语法树。下面是 Expr类型的定义:

type Expr
head::Symbol
args::Array{Any,1}
typ
end

head是标明表达式种类的符号;args是子表达式数组,它可能是求值时引用变量值的符号,也可能是嵌套的 Expr对象,还可能是真实的对象值。 typ域被类型推断用来做类型注释,通常可以被忽略。

有两种“引用”代码的方法,它们可以简单地构造表达式对象,而不需要显式构造 Expr对象。第一种是内联表达式,使用 :,后面跟单表达式;第二种是代码块儿,放在 quote ... end内部。下例是第一种方法,引用一个算术表达式:

julia> ex = :(a+b*c+1)
:(a + b * c + 1)
julia> typeof(ex)
Expr
julia> ex.head
:call
julia> typeof(ans)
Symbol
julia> ex.args
4-element Array{Any,1}:
:+
:a
:(b * c)
1
julia> typeof(ex.args[1])
Symbol
julia> typeof(ex.args[2])
Symbol
julia> typeof(ex.args[3])
Expr
julia> typeof(ex.args[4])
Int64

下例是第二种方法:

julia> quote
x = 1
y = 2
x + y
end
quote # none, line 2:
x = 1 # line 3:
y = 2 # line 4:
x + y
end

符号

:的参数为符号时,结果为 Symbol对象,而不是 Expr

julia> :foo
:foo
julia> typeof(ans)
Symbol

在表达式的上下文中,符号用来指示对变量的读取。当表达式被求值时,符号的值受限于符号的作用域(详见变量的作用域)。

有时, 为了防止解析时产生歧义,:的参数需要添加额外的括号:

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

Symbol也可以使用 symbol函数来创建,参数为一个字符或者字符串:

julia> symbol('\'')
:'
julia> symbol("'")
:'

求值和内插

指定一个表达式,Julia 可以使用 eval函数在 global 作用域对其求值。

julia> :(1 + 2)
:(1 + 2)
julia> eval(ans)
3
julia> ex = :(a + b)
:(a + b)
julia> eval(ex)
ERROR: a not defined
julia> a = 1; b = 2;
julia> eval(ex)
3

每一个组件 有在它全局范围内评估计算表达式的 eval表达式。传递给 eval的表达式不限于返回一个值 - 他们也会具有改变封闭模块的环境状态的副作用:

julia> ex = :(x = 1)
:(x = 1)
julia> x
ERROR: x not defined
julia> eval(ex)
1
julia> x
1

表达式仅仅是一个 Expr对象,它可以通过编程构造,然后对其求值:

julia> a = 1;
julia> ex = Expr(:call, :+,a,:b)
:(+(1,b))
julia> a = 0; b = 2;
julia> eval(ex)
3

注意上例中 ab使用时的区别:

  • 表达式构造时,直接使用变量 a的值。因此,对表达式求值时 a的值没有任何影响:表达式中的值为 1,与现在 a的值无关
  • 表达式构造时,使用的是符号 :b。因此,构造时变量 b的值是无关的—— :b仅仅是个符号,此时变量 b还未定义。对表达式求值时,通过查询变量 b的值来解析符号 :b的值

这样构造 Expr对象太丑了。Julia 允许对表达式对象内插。因此上例可写为:

julia> a = 1;
julia> ex = :($a + b)
:(+(1,b))

编译器自动将这个语法翻译成上面带 Expr的语法。

代码生成

Julia 使用表达式内插和求值来生成重复的代码。下例定义了一组操作三个参数的运算符: ::

for op = (:+, :*, :&, :|, :$)
eval(quote
($op)(a,b,c) = ($op)(($op)(a,b),c)
end)
end

上例可用 :前缀引用格式写的更精简: ::

for op = (:+, :*, :&, :|, :$)
eval(:(($op)(a,b,c) = ($op)(($op)(a,b),c)))
end

使用 eval(quote(...))模式进行语言内的代码生成,这种方式太常见了。Julia 用宏来简写这个模式: ::

for op = (:+, :*, :&, :|, :$)
@eval ($op)(a,b,c) = ($op)(($op)(a,b),c)
end

@eval宏重写了这个调用,使得代码更精简。 @eval的参数也可以是块代码:

@eval begin
# multiple lines
end

对非引用表达式进行内插,会引发编译时错误:

julia> $a + b
ERROR: unsupported or misplaced expression $

宏有点儿像编译时的表达式生成函数。正如函数会通过一组参数得到一个返回值,宏可以进行表达式的变换,这些宏允许程序员在最后的程序语法树中对表达式进行任意的转化。调用宏的语法为:

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

注意,宏名前有 @符号。第一种形式,参数表达式之间没有逗号;第二种形式,宏名后没有空格。这两种形式不要记混。例如,下面的写法的结果就与上例不同,它只向宏传递了一个参数,此参数为多元组 (expr1, expr2, ...)

@name (expr1, expr2, ...)

程序运行前, @name展开函数会对表达式参数处理,用结果替代这个表达式。使用关键字 macro来定义展开函数:

macro name(expr1, expr2, ...)
...
return resulting_expr
end

下例是 Julia 中 @assert宏的简单定义:

macro assert(ex)
return :($ex ? nothing : error("Assertion failed: ", $(string(ex))))
end

这个宏可如下使用:

julia> @assert 1==1.0
julia> @assert 1==0
ERROR: Assertion failed: 1 == 0
in error at error.jl:22

宏调用在解析时被展开为返回的结果。这等价于:

1==1.0 ? nothing : error("Assertion failed: ", "1==1.0")
1==0 ? nothing : error("Assertion failed: ", "1==0")

上面的代码的意思是,当第一次调用表达式 :(1==1.0)的时候,会被拼接为条件语句,而 string(:(1==1.0))会被替换成一个断言。因此所有这些表达式构成了程序的语法树。然后在运行期间,如果表达式为真,则返回 nothing,如果条件为假,一个提示语句将会表明这个表达式为假。注意,这里无法用函数来代替,因为在函数中只有值可以被传递,如果这么做的话我们无法在最后的错误结果中得到具体的表达式是什么样子的。

在标准库中真实的 @assert定义要复杂一些,它可以允许用户去操作错误信息,而不只是打印出来。和函数一样宏也可以有可变参数,我们可以看下面的这个定义:

macro assert(ex, msgs...)
msg_body = isempty(msgs) ? ex : msgs[1]
msg = string("assertion failed: ", msg_body)
return :($ex ? nothing : error($msg))
end

现在根据参数的接收数目我们可以把 @assert分为两种操作模式。如果只有一个参数,表达式会被 msgs捕获为空,并且如上面所示作为一个更简单的定义。如果用户填上第二个参数, 这个参数会被作为打印参数而不是错误的表达式。你可以在下面名为 macroexpand的函数中检查宏扩展的结果:

julia> macroexpand(:(@assert a==b))
:(if a == b
nothing
else
Base.error("assertion failed: a == b")
end)
julia> macroexpand(:(@assert a==b "a should equal b!"))
:(if a == b
nothing
else
Base.error("assertion failed: 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"))
ASCIIString (constructor with 2 methods)
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: ASCIIString "a ("
2: Symbol a
3: ASCIIString ") should equal b ("
4: Symbol b
5: ASCIIString ")!"
typ: Any

所以现在不应该得到一个面上的字符串 msg_body,这个宏接收整个表达式且需要如我们所期望的计算。这可以直接拼接成返回的表达式来作为 string调用的一个参数。通过看 error.jl源码得到完整的实现。

@assert宏极大地通过宏替换实现了表达式的简化功能。

卫生宏

卫生宏是个更复杂的宏。一般来说,宏必须确保变量的引入不会和现有的上下文变量发送冲突。相反的,宏中的表达式作为参数应该可以和上下文代码有机的结合在一起,进行交互。另一个令人关注的问题是,当宏用不同方式定义的时候是否被应该称为另一种模式。在这种情况下,我们需要确保所有的全局变量应该被纳入正确的模式中来。Julia 已经在宏方面有了很大的优势相比其它语言(比如 C)。所有的变量(比如 @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是标准库中的 time函数,而不是用户可能使用的某个叫 time的变量( println函数也如此)。

Julia 宏展开机制是这样解决命名冲突的。首先,宏结果的变量被分类为本地变量或全局变量。如果变量被赋值(且未被声明为全局变量)、被声明为本地变量、或被用作函数参数名,则它被认为是本地变量;否则,它被认为是全局变量。本地变量被重命名为一个独一无二的名字(使用 gensym函数产生新符号),全局变量被解析到宏定义环境中。

但还有个问题没解决。考虑下例:

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设置为 0 :

macro zerox()
return esc(:(x = 0))
end
function foo()
x = 1
@zerox
x # is zero
end

应审慎使用这种操作。

非标准字符串文本

字符串中曾讨论过带标识符前缀的字符串文本被称为非标准字符串文本,它们有特殊的语义。例如:

  • r"^\s*(?:#|$)"生成正则表达式对象而不是字符串
  • b"DATA\xff\u2200"是字节数组文本 [68,65,84,65,255,226,136,128]

事实上,这些行为不是 Julia 解释器或编码器内置的,它们调用的是特殊名字的宏。例如,正则表达式宏的定义如下:

macro r_str(p)
Regex(p)
end

因此,表达式 r"^\s*(?:#|$)"等价于把下列对象直接放入语法树:

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

这么写不仅字符串文本短,而且效率高:正则表达式需要被编译,而 Regex仅在 代码编译时 才构造,因此仅编译一次,而不是每次执行都编译。下例中循环中有一个正则表达式:

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

如果不想使用宏,要使上例只编译一次,需要如下改写:

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

由于编译器优化的原因,上例依然不如使用宏高效。但有时,不使用宏可能更方便:要对正则表达式内插时必须使用这种麻烦点儿的方式;正则表达式模式本身是动态的,每次循环迭代都会改变,生成新的正则表达式。

不止非标准字符串文本,命令文本语法( echo "Hello, $person")也是用宏实现的:

macro cmd(str)
:(cmd_gen($shell_parse(str)))
end

当然,大量复杂的工作被这个宏定义中的函数隐藏了,但是这些函数也是用 Julia 写的。你可以阅读源代码,看看它如何工作。它所做的事儿就是构造一个表达式对象,用于插入到你的程序的语法树中。

反射

除了使用元编程语法层面的反思,朱丽亚还提供了一些其他的运行时反射能力。

类型字段 数据类型的域的名称(或模块成员)可以使用 names命令来询问。例如,给定以下类型:

type Point
x::FloatingPoint
y
end

names(Point)将会返回指针 Any[:x, :y]。在一个 Point中每一个域的类型都会被存储在指针对象的 types域中:

julia> typeof(Point)
DataType
julia> Point.types
(FloatingPoint,Any)

亚型

任何数据类型的直接亚型可以使用 subtypes(t::DataType)来列表查看。例如,抽象数据类型 FloatingPoint包含四种(具体的)亚型::

julia> subtypes(FloatingPoint)
4-element Array{Any,1}:
BigFloat
Float16
Float32
Float64

任何一个抽象的亚型也将被列入此列表中,但其进一步的亚型则不会;“亚型”的递归应用程序允许建立完整的类型树。

类型内部

当使用到 C 代码接口时类型的内部表示是非常重要的。isbits(T::DataType)T存储在 C 语言兼容定位时返回 true 。每一个域内的补偿量可以使用 fieldoffsets(T::DataType)语句实现列表显示。

函数方法

函数内的所有方法可以通过 methods(f::Function)语句列表显示出来。

函数表示

函数可以在几个表示层次上实现内部检查。一个函数的更低形式在使用 code_lowered(f::Function, (Args...))时是可用的,而类型推断的更低形式在使用 code_typed(f::Function, (Args...))时是可用的。

更接近机器的是,LLVM 的中间表示的函数是通过 code_llvm(f::Function, (Args...))打印的,并且最终的由此产生的汇编指令在使用 code_native(f::Function, (Args...)时是可用的。