译:理解 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 中 getmatch 的调用会创建一个函数,而 ExActor 中的 defcall 会生成两个函数,以及能够把参数合理地从客户端进程传给服务器进程的代码。

Elixir 语言本身也很大程度上由宏驱动。许多结构,比如 defmoduledefifunless,甚至是 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.txtSpecialCasing.txt 来生成的,这些文件描述了码位(codepoint),许多函数(比如 upcasedowncase)都是基于这些数据而生成的。

在这两种情况中(宏和原地(in-place)代码生成),我们都在编译时对抽象语法树(AST)做了一些变换,为了了解这是如何工作的,你需要对编译过程和 AST 有一些了解。

编译过程 #

粗略地说,Elixir 代码的编译发生在这三个阶段:

concurrent_handling.png

输入的源代码被解析,产生了一个相应的抽象语法树(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 代码是一个三元组,包括了:

重点是,引用表达式是一个 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 的一个表示,而不管这些变量是否存在。最终代码还没有被执行,所以没有错误。

如果我们把这个引用表示插入一段 ab 是合法标识符的 AST 中,这段代码就是正确的。

让我们试一下。首先,我们来引用(quote)这个加法表达式:

iex(4)> sum_expr = quote do a + b end

然后我们来写一个引用的绑定表达式:

iex(5)> bind_expr = quote do
          a=1
          b=2
        end

再次注意,这些只是引用表达式。它们仅仅是描述代码的数据,但不会被求值,即便变量 ab 在当前 shell 会话中不存在也没关系。

为了使这些代码在一起工作,我们必须把它们连起来:

iex(6)> final_expr = quote do
          unquote(bind_expr)
          unquote(sum_expr)
        end

这里我们生成了一个新的引用表达式,它由 bind_exprsum_expr 组成,而不用管它们两个具体是什么。本质上,我们产生了一个新的 AST 片段,它把这两个表达式结合起来了。不要担心 unquote ,我很快就会解释。

这时候,我们就可以得到最后的 AST 片段了:

iex(7)> Code.eval_quoted(final_expr)
{3, [{{:a, Elixir}, 1}, {{:b, Elixir}, 2}]}

可以看到,结果由一个表达式(3)和绑定列表组成,这个列表由分别绑定了 12 的变量 ab 组成。

这就是 Elixir 中元编程的核心,当使用元编程时,我们实际上组合了不同的 AST 代码段来生成了另一个 AST,这其实就是我们想要生成的代码。在这个过程中,我们通常对输入 AST (我们用来组合的)的具体内容或结构不感兴趣,只是用 quote 来生成以及组合输入的片段,来产生最终经过加工的代码。

unquoting #

现在来讲 unquote。注意 quote 里被引用的任何东西都会被转变为 AST 片段,这意味着我们不能正常地把引用外部的一些变量直接注入进来。看下边这个例子,它不会正常运行:

quote do
  bind_expr
  sum_expr
end

在这段代码中,quote 仅仅生成了指向 bind_exprsum_expr的被引用(quoted)的引用(references),而这两个变量必须存在在这个上下文当中(在这里 AST 将会被转译),但是这并不是我们想要的。我们想要的是一种直接把 bind_exprsum_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]}]}

现在,输入看起来有点吓人。你通常不需要理解它,但如果看的够仔细的话,你就会发现这个结构的一些地方提到了 Tracertrace,这就说明了这个 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 了解不多的,可以买来看看

 
39
Kudos
 
39
Kudos

Now read this

How does Plug work with Cowboy?

Plug的文档里有个通过Plug写应用程序的简单例子: defmodule MyPlug do import Plug.Conn def init(options) do # initialize options options end def call(conn, _opts) do conn |> put_resp_content_type("text/plain") |> send_resp(200, "Hello world") end end #... Continue →