原文

这是 ‘Elixir中的宏’ 系列的第二篇。上一次我们讨论了编译过程和Elixir AST,最后讲了一个基本的宏的例子trace。今天,我会更详细地讲解宏的机制。

可能有一些内容会和上一篇重复,但我认为这对于理解运作原理和AST的生成很有帮助。掌握了这些以后,你对于自己的宏代码就更有信心了。基础很重要,因为随着更多地用到宏,代码可能会由许多的quote/unquote结构组成。

调用一个宏

最需要重视的是展开阶段。编译器在这个阶段调用了各种宏(以及其它代码生成结构)来生成最终AST。

例如,trace宏的典型用法是这样的:

defmodule MyModule do
  require Tracer
  ...
  def some_fun(...) do
    Tracer.trace(...)
  end
end

像之前所提到的那样,编译器从一个类似于这段代码的AST开始。这个AST之后会被扩展,然后生成最后的代码。因此,在这段代码的展开阶段,Tracer.trace/1会被调用。

我们的宏接受了输入的AST,然后必须生成输出的AST。之后编译器会简单地用输出的AST替换掉对宏的调用。这个过程是渐进的——一个宏所返回的AST中可能包含其它宏(甚至它本身)。编译器会再次扩展,直到没有什么可以扩展的。

调用宏使得我们有机会修改代码的含义。一个典型的宏会获取输入的AST并修饰它,在它周围添加一些代码。

那就是我们使用trace宏所做的事情。我们得到了一个引用(quoted)表达式(例如1+2),然后返回了这个:

result = 1 + 2
Tracer.print("1 + 2", result)
result

要在代码的任何地方调用宏(包括shell里),你都必须先调用require Tracerimport Tracer。为什么呢?因为宏有两个看似矛盾的性质:

  • 宏也是Elixir代码

  • 宏在扩展阶段运行,在最终的字节码生成之前

Elixir代码是如何在被生成之前运行的?它不能。要调用一个宏,其容器模块(宏的定义所在的模块)必须已经被编译。

因此,要运行Tracer模块中所定义的宏,我们必须确认它已经被编译了。也就是说,我们必须向编译器提供一个关于我们所需求的模块的信号。当我们require了一个模块,我们会让Elixir暂停当前模块的编译,直到我们require
的模块编译好并载入到了编译器的运行时(编译器所在的Erlang VM实例)。只有在Tracer模块完全编译好并对编译器可用的情况下,我们才能调用trace宏。

使用import也有相同效果,只不过它还在词法上引入了所有的公共函数和宏,使得我们可以用trace替代Tracer.trace

由于宏也是函数,而Elixir在调用函数时可以省略括号,所以我们这样写:

Tracer.trace 1+2

这很可能是Elixir之所以不在函数调用时要求括号的最主要原因。记住,大多数语言结构都是宏。如果括号是必须的,那么我们的代码会有更多噪声:

defmodule(MyModule, do:
  def(function_1, do: ...)
  def(function_2, do: ...)
)

清洁

在上一篇文章中我们提到,宏默认是清洁的。意思就是一个宏所引入的变量是其私有的,不会影响到其余的代码。这就是我们能够在我们的trace宏中安全地引入result变量的原因:

quote do
  result = unquote(expression_ast)  # result is private to this macro
  ...
end

这个变量不会与调用宏的代码相交互。在你调用了trace宏的地方,你可以自由地声明你自己的result变量,不会影响到trace宏里的result

大多时候清洁会如你所愿,但也有一些例外。有时,你可能需要创建一个对于调用了宏的代码可用的变量。让我们来从Plug库里找一个真实的应用情形,而不是构造一些不自然的例子。这是我们如何使用Plug router来分辨路径:

get "/resource1" do
  send_resp(conn, 200, ...)
end

post "/resource2" do
  send_resp(conn, 200, ...)
end

注意,两段代码中我们都用到了不存在的conn变量。这是因为get宏在生成的代码中绑定了这个变量。你可以想象到最终的代码会是这样:

defp do_match("GET", "/resource1", conn) do
  ...
end

defp do_match("POST", "/resource2", conn) do
  ...
end

注意: 真正由Plug生成的代码可能会有不同,这只是简化版。

这是一个不清洁的宏的例子,它引入了一个变量。变量conn是由宏get引入的,但其对于调用了宏的代码必须是可见的。

另一个例子是关于ExActor的。来看一下:

defmodule MyServer do
  ...
  defcall my_request(...), do: reply(result)
  ...
end

如果你对GenServer很熟悉,那么你知道一个call的结果必须是{:reply, response, state} 的形式。然而,在上述代码中,甚至没有提到state。那么我们是如何返回state的呢?这是因为defcall宏生成了一个隐藏的state变量,它之后将被reply宏明确使用。

在两种情况中,一个宏都必须创建一个不清洁的变量,而且必须是在宏所引用的代码之外可见。为达到这个目的,可以使用var!结构。这里是Plug的get宏的简化版本:

defmacro get(route, body) do
  quote do
    defp do_match("GET", unquote(route), var!(conn)) do
      # put body AST here
    end
  end
end

注意我们是如何使用var!(conn)的。这样,我们就明确了conn是一个对调用者可见的变量。

上述代码没有解释body是如何注入的。在这之前,你需要理解宏所接受的参数。

宏参数

你要记住,宏本质上是在扩展阶段被导入的Elixir函数,然后生成最终的AST。宏的特别之处在于它所接受的参数都是被引用的(quoted)。这就是我们之所以能够调用:

def my_fun do
  ...
end

它等同于:

def(my_fun, do: (...))

注意我们在调用def宏的时候,使用了不存在的变量my_fun。这是完全可以的,因为我们实际上传送的是quote(do: my_fun)的结果,而引用(quote)不要求变量存在。在内部,def宏会接收到包含了:my_fun的引用形式。def宏会使用这个信息来生成对应名称的函数。

这里再提一下do...end块。任何时候发送一个do...end块给一个宏,都相当于发送一个带有:do键的关键词列表。

所以,调用

my_macro arg1, arg2 do ... end

相当于

my_macro(arg1, arg2, do: ...)

这些只不过是Elixir中的语法糖。解释器将do..end转换成了{:do, …}

现在,我只提到了参数是被引用的。然而,对于许多常量(原子,数字,字符串),引用形式和输入值完全一样。此外,二元元组和列表会在被引用时保持它们的结构。这意味着quote(do: {a, b})将会返回一个二元元组,它的两个值都是被引用的。

让我们在shell中试一下:

iex(1)> quote do :an_atom end
:an_atom

iex(2)> quote do "a string" end
"a string"

iex(3)> quote do 3.14 end
3.14

iex(4)> quote do {1,2} end
{1, 2}

iex(5)> quote do [1,2,3,4,5] end
[1, 2, 3, 4, 5]

对三元元组的引用不会保留它的形状:

iex(6)> quote do {1,2,3} end
{:{}, [], [1, 2, 3]}

由于列表和二元元组在被引用时能保留结构,所以关键词列表也可以:

iex(7)> quote do [a: 1, b: 2] end
[a: 1, b: 2]

iex(8)> quote do [a: x, b: y] end
[a: {:x, [], Elixir}, b: {:y, [], Elixir}]

在第一个例子中,你可以看到输入的关键词列表完全没变。第二个例子证明了复杂的部分(例如调用xy)会是引用形式。但是列表还保持着它的形状。这仍然是一个键为:a:b的关键词列表。

放在一起

为什么这些都很重要?因为在宏代码中,你可以简单地从关键词列表中获取设置,不需要分析复杂的AST。让我们在简化的get宏中来实践一下。之前,我们有一段这样的代码:

defmacro get(route, body) do
  quote do
    defp do_match("GET", unquote(route), var!(conn)) do
      # put body AST here
    end
  end
end

记住,do..enddo: … 是一样的,所以当我们调用get route do … end,相当于调用get(route, do: …)。宏参数是被引用的,但我们已经知道关键词列表在被引用后仍然保持形状,所以我们能够使用body[:do]来从宏中获取被引用的主体(body):

defmacro get(route, body) do
  quote do
    defp do_match("GET", unquote(route), var!(conn)) do
      unquote(body[:do])
    end
  end
end

所以我们简单地将被引用的输入的主体注入到了我们所生成的do_match从句的主体中。

如之前所提到的,这就是宏的目的。它接受到了某个AST片段,然后用模板代码将它们结合起来,以生成最后的结果。理论上,当我们这样做时,不需要考虑输入的AST的内容。在例子中,我们简单地将主体注入到生成的函数中,没有考虑主体里有什么。

测试这个宏很简单。这里是将被require的最少代码:

defmodule Plug.Router do
  # get macro removes the boilerplate from the client and ensures that
  # generated code conforms to some standard required by the generic logic
  defmacro get(route, body) do
    quote do
      defp do_match("GET", unquote(route), var!(conn)) do
        unquote(body[:do])
      end
    end
  end
end

现在我们可以实现一个客户端模块:

defmodule MyRouter do
  import Plug.Router

  # Generic code that relies on the multi-clause dispatch
  def match(type, route) do
    do_match(type, route, :dummy_connection)
  end

  # Using macro to minimize boilerplate
  get "/hello", do: {conn, "Hi!"}
  get "/goodbye", do: {conn, "Bye!"}
end

以及测试:

MyRouter.match("GET", "/hello") |> IO.inspect
# {:dummy_connection, "Hi!"}

MyRouter.match("GET", "/goodbye") |> IO.inspect
# {:dummy_connection, "Bye!"}

注意match/2的代码。它是通用的代码,依赖于do_match/3的实现。

使用模块

观察上述代码,你可以看到match/2的胶水代码存在于客户端模块中。这肯定成不上完美,因为每个客户端都必须提供对这个函数的正确实现,而且必须调用do_match函数。

更好的选择是,Plug.Router抽象能够将这个实现提供给我们。我们可以使用use宏,大概就是其它语言中的mixin。

大体上是这样的:

defmodule ClientCode do
  # invokes the mixin
  use GenericCode, option_1: value_1, option_2: value_2, ...
end

defmodule GenericCode do
  # called when the module is used
  defmacro __using__(options) do
    # generates an AST that will be inserted in place of the use
    quote do
      ...
    end
  end
end

use机制允许我们将某段代码注入到调用者的内容中。就像是替代了这些:

defmodule ClientCode do
  require GenericCode
  GenericCode.__using__(...)
end

你可以查看Elixir的源代码来证明。这也证明了另一点——增量扩展。use宏生成的代码将会调用别的宏。更好玩的说法就是,use生成了那些生成代码的代码。就像之前提到的,编译器会简单地再次进行扩展,直到没有东西可扩展了。

知道了这些,我们可以将match函数的实现放到通用的Plug.Router模块中:

defmodule Plug.Router do
  defmacro __using__(_options) do
    quote do
      import Plug.Router

      def match(type, route) do
        do_match(type, route, :dummy_connection)
      end
    end
  end

  defmacro get(route, body) do
    ... # This code remains the same
  end
end

现在客户端的代码就非常简洁了:

defmodule MyRouter do
  use Plug.Router

  get "/hello", do: {conn, "Hi!"}
  get "/goodbye", do: {conn, "Bye!"}
end

__using__宏生成的AST会简单地被注入到调用use Plug.Router的地方。特别注意我们是如何从__using__宏里使用import Plug.Router的。这不是必须的,但它能让客户端使用get替代Plug.Router.get

那么我们得到了什么?各种模板汇集到了一个地方(Plug.Router)。不仅仅简化了客户端代码,也让这个抽象保持正确关闭。模块Plug.Router确保了get宏所生成的任何东西都能适合通用的match代码。在客户端中,我们只要use那个模块,然后用它提供的宏来组合我们的路径。

总结一下本章的内容。许多细节没有提到,但希望你对于宏是如何与Elixir编译器相结合的有了更好的理解。在下一部分,我会更深入,并开始探索如何分解输入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.


Ljzn
399 声望102 粉丝

网络安全;函数式编程;数字货币;人工智能