(译)理解Elixir宏第2部分

这是理解Elixir宏系列文章的第二篇. 上一篇讨论了编译阶段Elixir宏

调用宏

要知道在编译过程的展开阶段最重要的事情是: 编译器调用各种宏(以及其他代码生成构造), 并产生最终的AST.

例如, 一个经典的对trace宏的使用示例如下:

1
# 定义了我们的应用模块
defmodule MyModule do
  # 包含了我们需要用到的代码调试和追踪模块
  require Tracer
  ...
  def some_fun(...) do
    # 调用具体的追踪模块调试代码
    Tracer.trace(...)
  end
end

Hygiene

上一章提到过, 宏默认是hygienic的. 其含义是: 宏引入的变量有其自己的私有作用域, 不会影响代码的其他部分. 这就是为什么我们能安全地在trace宏中引入result变量.

1
quote do
    result = unquote(expression_ast) # result 变量是宏的私有变量
    ...
end

该变量不会干扰调用这个宏的代码. 在调用宏的地方, 可以随意的声明你自己的result变量, 它不会被tracer宏中的result变量隐藏.

大多数时候hygiene是我们想要的效果, 但是也有例外. 有时候, 可能需要创建在调用者作用域内可用的变量. 下面我们通过Plug库的一个用例来演示, 我们如何使用Plug来制定路由:

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

注意,上面这两个宏如何使用并不存在的conn变量. 这是因为, get宏在生成的代码中绑定了该变量. 可以想象一下, 产生的代码如下:

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

注意: Plug产生的真实代码是不同的, 这里为了演示对其进行了简化.

这是一个例子, 宏引入了一个变量, 必须不是hygienic. 变量connget宏引入, 必须对调用者可见.

宏参数

揉到一起

使用模块

查看上面的代码, 函数match/2是在客户端模块中实现. 这确实是太不完美了, 因为每个客户端模块都必须提供该函数的正确实现,并知道do_match函数如何调用.

如果Plug.Router能够提供match/2的实现是不是更好. 对此我们可以采用use宏, 它初略地等同于其他语言中的Mixin.

普遍的思想是:

1
# 客户端模块部分
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机制允许我们在调用者的上下文种注入代码, 这类似于如下这种替代形式:

1
defmodule ClientCode do
    # 加载通用模块
    require GenericCode
    # 调用通用模块中的公共函数
    GenericCode.__using__(...)
end

其可以通过use宏的Elixir实现源代码得到证明. 同时还证明了另一点 - 增量展开.

use宏生成了调用其他宏的代码. 说的更明白一点, use生成了用于生成代码的代码(use生成了代码,生成的代码又用于生成其他的代码), 早前我们曾说过, 编译器会递归地展开它所发现的所有宏定义, 直到没有可展开的宏为止.

拥有了这方面的知识, 现在可以转移到Plug.Router通用模块的match函数的实现.

1
defmodule Plug.Router do
  defmacro __using__(_options) do
    # 译注:
    # 在调用者use本模块的时候返回一个AST结构,并插入到use被使用的位置
    # 如果你有Web的开发经验, 类似于在DOM树中插入一个子树或子节点,用于改变整个DOM树的结构,
    # 类似的__using__宏返回一个AST片段, 并插入到调用代码的位置, 也就是在客户端模块中`use`宏使用的位置
    quote do
      # 导入自身, 以便在客户端代码中不必以`Plug.Router.get`的形式使用,而是直接使用`get`
      import Plug.Router
      def match(type, route) do
        do_match(type, route, :dummy_connection)
      end
    end
  end
  defmacro get(route, body) do
    ... # 这里的代码保持原样
  end
end

这样, 我们就可以把客户端模块的代码保持的非常苗条:

1
defmodule MyRouter do
  # 使用Plug.Router模块,同时在编译阶段(具体实际上是展开阶段),运行该模块中的`__using__`宏,
  # 并把`__using__`返回的AST片段插入到当前位置
  use Plug.Router
  get "/hello", do: {conn, "Hi!"}
  get "/goodbye", do: {conn, "Bye!"}
end

前面曾提到, 由__using__宏生成的AST片段只是简单地插入到use Plug.Router调用的位置. 特别注意的是, 在__use___宏定义中的import Plug.Router, 严格意义上并不需要这行代码, 它只是使客户端代码变得更加简洁一些, 你可以使用get, 而不必输入其全称Plug.Router.get.

下面这段就不译了, 一堆废话, 看得懂的看, 看不懂的可以忽略.

So what have we gained? The various boilerplate is now confined to the single place (Plug.Router).
Not only does this simplify the client code, it also keeps the abstraction properly closed.
The module Plug.Router ensures that whatever is generated by get macros fits properly with the generic code of match.
As clients, we simply use the module and call into the provided macros to assemble our router.

This concludes today’s session. Many details are not covered,
but hopefully you have a better understanding of how macros integrate with the Elixir compiler.
In the next part I’ll dive deeper and start exploring how we can tear apart the input AST.

译注

对于use的使用我们可以理解为:

  • PHP中的require 'database.php'include 'database.php'
  • Java中的import com.mysql.jdbc.Driver;
  • Python中的import amqp
  • C中的include <stdlib.h>

它是在编译过程中的展开阶段对代码(AST)进行修改和装饰, 并把修改过的代码(AST)插入到使用的位置.

原文

http://www.theerlangelist.com/2014/06/understanding-elixir-macros-part-2.html