[elixir! #0003] [译] 理解Elixir中的宏——part.1 基础 by Saša Jurić
这是讲解macro(宏)的系列文章中的第一篇。我本来计划将这个话题放在我的书《Elixir in Action》里,但还是放弃了,因为那本书的主要关注的是底层VM和OTP中重要的部分。
所以,我决定在这里讲解宏。我发现关于宏的话题十分有趣,在这一系列的文章中,我将试图解释宏是如何运作的,并以供一些编写宏的基本技巧和建议。尽管我认为编写宏并不是很难,但相较于普通Elixir代码,它确实需要更高一层的视角。因此,我认为这对于理解Elixir编译器的内部细节很有帮助。知道了事物内部是如何运作的之后,就能更容易地理解元编程代码。
这是中等难度的内容。如果你很熟悉Elixir和Erlang,但对宏还感觉困惑,那么这很适合你。如果你刚开始接触Elixir和Erlang,那么最好从其它地方开始。
元编程
也许你已经听说过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
模块。这个模块基本上是通过读取UnicodeData.txt
和SpecialCasing.txt
文件里表述的代码点来生成的。基于文件中的数据,各种函数(例如upcase
, downcase
) 被生成了。
无论是宏还是代码生成,我们都在编译的过程中对抽象语法树做了某些变换。为了理解它是如何工作的,你需要学习一点编译过程和AST的知识。
编译过程
概括地说,Elixir代码的编译有三个阶段:
输入的源代码被解析,然后生成相应的抽象语法树(AST)。AST会以嵌套的Elixir语句的形式来表现你的代码。然后展开阶段开始。在这个阶段,各种内置的和自定义的宏被转换成了最终版本。一旦转换结束,Elixir就可以生成最后的字节码,即源程序的二进制表示。
这只是一个概述。例如,Elixir编译器会生成Erlang AST,然后依赖Erlang函数将其转换为字节码,但是我们不需要知道细节。不过,这幅图对于理解元编程代码确实有帮助。
首先我们要知道,元编程的魔法发生在展开阶段。编译器先以一个类似于你的原始Elixir代码的AST开始,然后展开为最终版本。
另外,在生成了二进制之后,元编程就停止了。你可以确定你的代码不会被重新定义,除非代码升级或是一些动态的代码载入技巧(在本文内容之外)。因为元编程总是会引入一个隐形(或不明显)的层,在Elixir中这只发生在编译时,并独立于程序的各种执行路径。
代码转换发生在编译时,因此推导最终产品会相对简单,而且元编程不会干扰例如dialyzer的静态分析工具。编译时元编程也意味着我们不会有性能损失。进入运行时后,代码中就没有元编程结构了。
创建AST片段
那么什么是Elixir AST。它是Elixir代码所对应的深嵌套格式。让我们来看一些例子。你可以使用quote
特殊形式来生成代码的AST:
iex(1)> quoted = quote do 1 + 2 end {:+, [context: Elixir, import: Kernel], [1, 2]}
quote获取任意的Elixir表达式,并返回对应的AST片段。
在这里,AST片段表达了简单的加法操作(1+2
)。它通常被称为一个 quoted 表达式。
大多是时候你不需要明白quoted结构中的细节,但是让我们来观察一下这个简单的例子。在这里,我们的AST片段可以分为三个部分:
一个原子表示所要调用的操作(
:+
)表达式的环境(例如 imports和aliases)。通常你不需要理解这个数据
参数
简而言之,在Elixir中,quoted表达式是用来描述代码的。编译器会用它来生成最后的字节码。
我们也可以对quoted表达式求值,尽管很少用到:
iex(2)> Code.eval_quoted(quoted) {3, []}
返回的元组中包含了表达式的结果,以及一个列表,其中包含了表达式中产生的变量绑定。
然而,在AST求值之前(通常由编译器来做),quoted表达式还没有接受语义验证。例如,当我们这样写:
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
的quoted表示,这意味着我们生成了对表达式a+b
的描述,而不用管这些变量是否存在。最终代码还没有发出,所以不会报错。
如果我们将其加入到某个a
和b
为合法变量的AST中,则代码可以正确运行。
让我们来试一下。首先,quote求和表达式:
iex(4)> sum_expr = quote do a + b end
然后创建一个quote了的绑定表达式:
iex(5)> bind_expr = quote do a=1 b=2 end
记住,它们只是quoted表达式。它们只是在描述代码,并没有执行。这时,变量a
和b
并不存在于当前shell会话中。
为了让这些片段一起工作,我们必须连接它们:
iex(6)> final_expr = quote do unquote(bind_expr) unquote(sum_expr) end
在这里,我们生成了一个新的quoted表达式,由bind_expr
的内容和sum_expr
的内容组成。事实上,我们生成了一个包含着两个表达式的新的AST片段。不要担心unquote
,我会在稍后解释它。
与此同时,我们可以执行这个最后的AST片段:
iex(7)> Code.eval_quoted(final_expr) {3, [{{:a, Elixir}, 1}, {{:b, Elixir}, 2}]}
结果又一次由表达式的结果(3
)和绑定列表组成,可以看出我们的表达式将两个变量a
和b
绑定到了值1
和2
。
这就是Elixir中元编程方法的核心。当进行元编程时,我们本质上是在构建各种AST片段,以生成一些代表着我们想要得到的代码的AST。在这过程中,我们通常对输入的AST片段(我们所结合的)的确切内容和结构不感兴趣。相反,我们使用quote
来生成并结合输入片段,以生成一些修饰好了的代码。
Unquoting
unquote
在这里出现了。注意,无论quote
块里有什么,它都会变成AST片段。这意味着我们不可以简单地将外部的变量注入到我们的quote里。例如,这样是不能达到效果的:
quote do bind_expr sum_expr end
在这里,quote
只是简单地生成了对于bind_expr
和sum_expr
变量的quoted标记,它们必须存在于这个AST可以被理解的环境里。然而,这不是我们想要的。我们想要的是直接注入bind_expr
和sum_expr
的内容到我们所生成的AST片段中对应的地方。
这就是unquote(…)
的目的——括号里的表达式会被立刻执行,然后插入到调用了unquote
的地方。这意味着unquote
的结果必须是合法的AST片段。
我们也可将unquote
类比于字符串插值(#{}
)。对字符串你可以这样做:
"... #{some_expression} ... "
类似地,quote时你可以这样做:
quote do ... unquote(some_expression) ... end
两种情形下,你都要执行一个在当前环境中合法的表达式,并将结果注入到你正在构建的表达式中(或字符串,AST片段)。
这很重要,因为unquote
并不是quote
的逆操作。quote
将一段代码转换成quoted表达式,unquote
并没有做逆向操作。如果你想将一个quoted表达式转换成一个字符串,你可以使用Macro.to_string/1
。
例子:跟踪表达式
让我们来实践一下。我们将编写一个帮助我们排除故障的宏。这个宏可以这样用:
iex(1)> Tracer.trace(1 + 2) Result of 1 + 2: 3 3
Tracer.trace
接受了一个表达式,并打印出了它的结果。然后返回的是表达式的结果。
需要认识到这是一个宏,它的输入(1+2
)可以被转换成更复杂的形式——打印表达式的结果并返回它。这个变换会发生在展开期,而结果的字节码会包含一些修饰过的输入代码。
在查看它的实现之前,想象一下或许会很有帮助。当我们调用Tracer.trace(1+2)
,结果的字节码会对应这些:
mangled_result = 1+2 Tracer.print("1+2", mangled_result) mangled_result
名称mangled_result
表明Elixir编译器会损毁所有在宏里引用的临时变量。这也被称为宏清洗,我们会在本系列之后的内容中讨论它(不在本文)。
因此,这个宏的定义可以是这样的:
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表达式。这一点非常重要——无论你发送了什么参数给一个宏,它们都已经是quoted的。所以,当我们调用Tracer.trace(1+2)
,我们的宏(它是一个函数)不会接收到3
。相反,expression_ast
的内容会是quote(do: 1+2)
的结果。
在第三行,我们使用Macro.to_string/1
来求出我们所收到的AST片段的字符串形式。这是你在运行时不能够对一个普通函数做的事之一。虽然我们能在运行时调用Macro.to_string/1
,但问题在于我们没办法再访问AST了,因此不能够知道某些表达式的字符串形式了。
一旦我们拥有了字符串形式,我们就可以生成并返回结果AST了,这一步是在quote do … end
结构中完成的。它的结果是用来替代原始的Tracer.trace(…)
调用的quoted表达式。
让我们进一步观察这一部分:
quote do result = unquote(expression_ast) Tracer.print(unquote(string_representation), result) result end
如果你明白unquote
的作用,那么就很简单了。实际上,我们是在把expression_ast
(quoted 1+2
)代入到我们生成的片段中,将表达式的结果放入result
变量。然后我们使用某种格式来打印它们(借助Macro.to_string/1
),最后返回结果。
展开一个AST
在shell里可以很容易地观察它。启动iex,然后复制粘贴Tracer
模块的定义:
iex(1)> defmodule Tracer do ... end
然后,你必须requireTracer
模块:
iex(2)> require Tracer
接下来,quote一个对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片段对应着我们的原始代码,它还没有被展开。
现在,该开始展开这个AST了,使用Macro.expand/2
:
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有所了解,也看过了一个简单的宏的例子。后续,我们将更深入地讨论宏的一些机制。
Copyright 2014, Saša Jurić. This article is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.
The article was first published on the old version of the Erlangelist site.
The source of the article can be found here.