[elixir! #0004] [译] 理解Elixir中的宏——part.2 微观理论 by Saša Jurić

坚强00CSDN 2019-06-20

原文

这是 ‘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.

相关推荐

LustrousElixir / 0评论 2013-02-25

探手摘星辰 / 0评论 2015-08-02