译:理解 Elixir 的宏-1
(原作者这一系列讲 Elixir macro 的文章写的非常好,我的认识还远远不够,所以在这里翻译一下,这是第一篇。翻译水平有限,见谅)
原文链接: http://www.theerlangelist.com/2014/06/understanding-elixir-macros-part-1.html
这是讨论宏(macro)的一系列文章的第一篇。我原本打算在之后要出版的 <Elixir in Action> 一书中来探讨这个问题,但又决定不这么做了。因为这个问题跟这本书的主题不太切合,这本书更关注于底层 VM 和 OTP 的关键部分。
于是我决定在这里探讨宏。个人而言,我觉得宏的问题很有意思,在这个系列里,我会试着解释它们是如何工作的,并对于如何写宏介绍一些基本的技巧和建议。尽管我确信写宏并不怎么难,但它确实需要一些比对一般 Elixir 代码,更高程度的认知,因此我认为了解 Elixir 编译器的一些内部细节是很有帮助的。知道幕后的事物是怎样运转的,能够让你对于元编程代码的思考更加容易。
这是一篇中等难度的文章,如果你对 Elixir 和 Erlang 比较熟悉,但对宏还是有一些疑惑,那么你找对地方了。如果你刚接触这两种语言,那最好还是先从其他的开始,比如 Getting started guide 或者干脆找本书来看看。
元编程 #
很可能你已经对 Elixir 的元编程有一些熟悉了,基本上就是,我们有一些代码,这些代码能结合一些输入来生成其他代码。
借助宏,我们能够写出像 Plug 里一样的结构
get "/hello" do
send_resp(conn, 200, "world")
end
match _ do
send_resp(conn, 404, "oops")
end
或者这个 ExActor 里的例子
defmodule SumServer do
use ExActor.GenServer
defcall sum(x, y), do: reply(x+y)
end
这两种情况中,我们都在编译时执行了一些自定义的宏,它们从原来的代码被转变成其他的。Plug 中 get
和 match
的调用会创建一个函数,而 ExActor 中的 defcall
会生成两个函数,以及能够把参数合理地从客户端进程传给服务器进程的代码。
Elixir 语言本身也很大程度上由宏驱动。许多结构,比如 defmodule
,def
,if
,unless
,甚至是 defmacro,其实都是宏。这使得语言的核心保持最小化,并且使它的进一步扩展更简单。
相关的,但是较少为人所知的是动态地生成函数:
defmodule Fsm do
fsm = [
running: {:pause, :paused},
running: {:stop, :stopped},
paused: {:resume, :running}
]
for {state, {action, next_state}} <- fsm do
def unquote(action)(unquote(state)), do: unquote(next_state)
end
def initial, do: :running
end
Fsm.initial
# :running
Fsm.initial |> Fsm.pause
# :paused
Fsm.initial |> Fsm.pause |> Fsm.pause
# ** (FunctionClauseError) no function clause matching in Fsm.pause/1
这里,我们有一个 FSM(有限状态机)的声明式描述,它会在编译时被转变成相应的多从句函数。
类似的技巧在 Elixir 中也有用到,比如用来生成 String.Unicode
module。基本上,这个模块是读取了文件 UnicodeData.txt
和 SpecialCasing.txt
来生成的,这些文件描述了码位(codepoint),许多函数(比如 upcase
,downcase
)都是基于这些数据而生成的。
在这两种情况中(宏和原地(in-place)代码生成),我们都在编译时对抽象语法树(AST)做了一些变换,为了了解这是如何工作的,你需要对编译过程和 AST 有一些了解。
编译过程 #
粗略地说,Elixir 代码的编译发生在这三个阶段:
输入的源代码被解析,产生了一个相应的抽象语法树(AST),它以嵌套 Elixir 语句的形式表示你的代码,之后展开(expansion)的过程就开始了。就是在这个阶段(展开),很多内建的和自定义的宏被调用,把输入的 AST 转变成最终形式。一旦这个转变完成了,Elixir 就能够产生最终的字节码了——源程序的二进制表示。
这只是这个过程的一个近似,比如,Elixir 编译器实际生成了 Erlang 的 AST, 并且依靠 Erlang 的 函数来把它转变成字节码。但是,知道准确的细节并不重要,在讨论元编程代码时,这个图片反而更有帮助。
主要得明白,元编程的魔法发生在拓展的阶段。编译器从一个几乎等同于你原始 Elixir 代码的 AST 开始,把它拓展成为最终形式。
这个图里另一个重要且值得注意的地方是,元编程在你程序的二进制表示产生后就停止了。除了代码升级或者一些动态代码加载的魔法(这超出了本文范围),你能够确定你的代码没有被重定义。尽管元编程总会引入一个不可见(或者不明显)的层到你的代码,但至少在 Elixir 中,这只发生在编译时,所以是程序中独立的执行路径。
知道了代码转变只发生在编译时,就能够更容易地思考最终产物了。元编程并不妨碍静态分析工具,比如 dialyzer。发生在编译时的元编程意味着我们没有效率上的损失,因为一旦到了运行时,代码就已经是那个样子了,没有元编程的结构在运行了。
创建 AST 代码片段 #
那么,什么是 AST 呢?它是一个 Elixir 的术语——一个深度的嵌套层级(deep nested hierarchy),用来表示一段语法上正确的 Elixir 代码。为了讲的更明白,让我们来看一些例子,为了生成一些代码的 AST,你需要使用 quote
这个特殊形式(special form):
iex(1)> quoted = quote do 1 + 2 end
{:+, [context: Elixir, import: Kernel], [1, 2]}
quote
接受任意复杂的 Elixir 表达式,并返回对应的 AST 代码,来描述输入的代码。
在我们的例子中,结果是一个用来表示简单的加操作 (1+2)
的 AST 片段。这通常叫做引用表达式(quoted expression)。
大多时候,你不需要理解被引用(quoted)结构的具体细节,但还是让我们来看一下刚刚这个例子。其中,我们的 AST 代码是一个三元组,包括了:
- 一个 atom,定义了将被调用的操作符(
:+
) - 表达式的上下文(比如引入 imports 和别名 aliases),大多情况你不需要理解这些结构
- 这个操作的参数(operands)
重点是,引用表达式是一个 Elixir 用来表示代码的术语,编译器将会用它来生成最终的字节码。
尽管不是很常见,但也可以求一个引用表达式的值:
iex(2)> Code.eval_quoted(quoted)
{3, []}
结果元组中包含了表达式的计算结果,和表达式中变量绑定(variable bindings)的列表。
但是,在 AST 被计算之前(通常由编译器完成),引用表达式没有从语义层面被验证。比如,当我们写下面这个表达式时:
iex(3)> a + b
** (RuntimeError) undefined function: a/0
我们得到一个错误,因为没有叫作 a
的变量(或函数)。
相反,如果我们引用(quote)这个表达式:
iex(3)> quote do a + b end
{:+, [context: Elixir, import: Kernel], [{:a, [], Elixir}, {:b, [], Elixir}]}
没有错误,而且我们得到了一个 a+b
的引用表示。这意味着,我们生成了描述表达式 a+b
的一个表示,而不管这些变量是否存在。最终代码还没有被执行,所以没有错误。
如果我们把这个引用表示插入一段 a
和 b
是合法标识符的 AST 中,这段代码就是正确的。
让我们试一下。首先,我们来引用(quote)这个加法表达式:
iex(4)> sum_expr = quote do a + b end
然后我们来写一个引用的绑定表达式:
iex(5)> bind_expr = quote do
a=1
b=2
end
再次注意,这些只是引用表达式。它们仅仅是描述代码的数据,但不会被求值,即便变量 a
和 b
在当前 shell 会话中不存在也没关系。
为了使这些代码在一起工作,我们必须把它们连起来:
iex(6)> final_expr = quote do
unquote(bind_expr)
unquote(sum_expr)
end
这里我们生成了一个新的引用表达式,它由 bind_expr
和 sum_expr
组成,而不用管它们两个具体是什么。本质上,我们产生了一个新的 AST 片段,它把这两个表达式结合起来了。不要担心 unquote
,我很快就会解释。
这时候,我们就可以得到最后的 AST 片段了:
iex(7)> Code.eval_quoted(final_expr)
{3, [{{:a, Elixir}, 1}, {{:b, Elixir}, 2}]}
可以看到,结果由一个表达式(3
)和绑定列表组成,这个列表由分别绑定了 1
和 2
的变量 a
和 b
组成。
这就是 Elixir 中元编程的核心,当使用元编程时,我们实际上组合了不同的 AST 代码段来生成了另一个 AST,这其实就是我们想要生成的代码。在这个过程中,我们通常对输入 AST (我们用来组合的)的具体内容或结构不感兴趣,只是用 quote
来生成以及组合输入的片段,来产生最终经过加工的代码。
unquoting #
现在来讲 unquote
。注意 quote
里被引用的任何东西都会被转变为 AST 片段,这意味着我们不能正常地把引用外部的一些变量直接注入进来。看下边这个例子,它不会正常运行:
quote do
bind_expr
sum_expr
end
在这段代码中,quote
仅仅生成了指向 bind_expr
和 sum_expr
的被引用(quoted)的引用(references),而这两个变量必须存在在这个上下文当中(在这里 AST 将会被转译),但是这并不是我们想要的。我们想要的是一种直接把 bind_expr
和 sum_expr
所代表的内容,注入到我们生成的 AST 片段相应位置的方法。
这就是 unquote(...)
的作用——括号里的表达式会被立即计算,再插入到 unquote
被调用的位置。这也意味着 unquote
的结果必须是一个有效的 AST 代码段。
另外一种看待 unquote
的方法是把它当做字符串插值(#{}
)的类似物,对于字符串你可以这样做:
"... #{some_expression} ... "
相似地,当计算引用时你能这样做:
quote do
...
unquote(some_expression)
...
end
在这两种情况中,你都在计算一个必须在当前上下文中有效的表达式,并把结果注入你正在组成的表达式(字符串,或者 AST 片段)中。
明白这个很重要,因为 unquote
并不是 quote
的一个逆操作。quote
把一段代码转变为引用表达式,而 unquote
并不做相反的事。如果你想要把一个引用表达式转变为代码字符串,你可以用 Macro.to_string/1
。
例子:跟踪表达式 #
让我们结合这个理论来看一个小例子。我们会写一个宏来帮助我们调试代码,这是这个宏的使用方法:
iex(1)> Tracer.trace(1 + 2)
Result of 1 + 2: 3
3
Tracker.trace
接受一个表达式,并把它打印出来,并且表达式的计算结果也会被返回。
知道这是一个宏很重要,这意味着输入的表达式 (1+2)
会被转变为更复杂的东西——一段先打印我们需要的结果再返回表达式本身计算结果的代码。这个转变将发生在展开阶段,产生的字节码会包含被修饰过的输入代码。
在看具体实现之前,先想一下最终结果可能会有所帮助。当我们调用 Tracer.trace(1+2)
时,产生的字节码应该是这样的:
mangled_result = 1+2
Tracer.print("1+2", mangled_result)
mangled_result
mangled_result
这个变量名字暗示 Elixir 编译器会销毁我们在 macro 中引入的所有临时变量,这也叫做宏卫生(macro hygiene)。我们将在本系列后边讨论(尽管不在这篇文章中)。
有了上边那个模板,这是这个宏的实现:
defmodule Tracer do
defmacro trace(expression_ast) do
string_representation = Macro.to_string(expression_ast)
quote do
result = unquote(expression_ast)
Tracer.print(unquote(string_representation), result)
result
end
end
def print(string_representation, result) do
IO.puts "Result of #{string_representation}: #{inspect result}"
end
end
让我们来逐步分析这段代码。
首先,我们用 defmacro
定义了这个宏。一个宏本质上就是一种特殊的函数,它的名字最终会被销毁,这个函数将只在展开阶段被调用(尽管理论上你仍然可以在运行时调用)。
我们的宏调用会收到一个引用表达式。一定要记得,这点非常重要——无论你传什么给一个宏时,它们就已经被引用(quoted)了。所以,当我们调用 Tracer.trace(1+2)
时,我们的宏(它不是一个函数)不会收到 3
,而是收到 quote(do: 1+2)
的结果。
在第三行,我们用 Macro.to_string/1
来获取参数 AST 片段的字符串表示。这是件你不能在运行时,通过调用一个普通的函数来做到的事。尽管可以在运行时调用 Macro.to_string
,但问题是,我们已经不再有 AST 了,所以也不会知道某个表达式的字符串表示是什么样子的。
一旦我们有了一个字符串表示,就能够生成并返回产生的 AST,这是通过 quote do ... end
结构来完成的。结果是一个将会替换掉原来的 Tracer.trace(...)
调用的引用表达式。
让我们仔细看这部分:
quote do
result = unquote(expression_ast)
Tracer.print(unquote(string_representation), result)
result
end
如果你明白了 unquote
,那这段代码会非常容易理解。大体上,我们注入了 expression_ast
(引用的 1+2
)到我们正在生成的那段代码,把它的计算结果存在变量 result
中。然后,我们打印出了这个结果和那个字符串化的表达式(通过 Macro.to_string/1
获得),并最终返回了那个计算结果(result
)。
展开一个 AST #
在 shell 里能够很容易看到这是如何工作的。启动一个 iex
shell,并把上边 Tracer
的定义复制到里边:
iex(1)> defmodule Tracer do
...
end
然后你必须 require Tracer
模块:
iex(2)> require Tracer
之后,让我们引用一个对于 trace
宏的调用:
iex(3)> quoted = quote do Tracer.trace(1+2) end
{{:., [], [{:__aliases__, [alias: false], [:Tracer]}, :trace]}, [],
[{:+, [context: Elixir, import: Kernel], [1, 2]}]}
现在,输入看起来有点吓人。你通常不需要理解它,但如果看的够仔细的话,你就会发现这个结构的一些地方提到了 Tracer
和 trace
,这就说明了这个 AST 代码对应于我们的原始代码,而且还没有被展开。
现在,我们能够通过 Macro.expand/2
来把这个 AST 变成一个展开过的版本:
iex(4)> expanded = Macro.expand(quoted, __ENV__)
{:__block__, [],
[{:=, [],
[{:result, [counter: 5], Tracer},
{:+, [context: Elixir, import: Kernel], [1, 2]}]},
{{:., [], [{:__aliases__, [alias: false, counter: 5], [:Tracer]}, :print]},
[], ["1 + 2", {:result, [counter: 5], Tracer}]},
{:result, [counter: 5], Tracer}]}
现在,这个就是我们代码的展开版本了。在里边一些地方你可以发现 result
(宏中引入的临时变量),以及 Tracer.print/2
的调用。你甚至能把这个表达式变成一个字符串:
iex(5)> Macro.to_string(expanded) |> IO.puts
(
result = 1 + 2
Tracer.print("1 + 2", result)
result
)
所有这些都证明了,你的宏调用真的被转变成了别的什么。这就是宏如何工作的,尽管我们只从 shell 里试了一下,但当我们用 mix
或者 elixirc
来构建我们的项目时,也发生了同样的事情。
我想以上这些对于我们的第一节来讲已经足够了,你已经了解了一些编译过程和 AST,以及看了一个非常简单的宏的例子。在下个部分,我将更深入地去讨论一些宏的机制方面的问题。
(完)
PS:原作者那本 <Elixir in Action> 也真的写得很好,我这里插个硬广告无偿帮他宣传一下,特别是想学 Elixir 但对 Erlang 了解不多的,可以买来看看