[elixir! #0004] [译] 理解Elixir中的宏——part.2 微观理论 by Saša Jurić
这是 ‘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 Tracer
或import 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}]
在第一个例子中,你可以看到输入的关键词列表完全没变。第二个例子证明了复杂的部分(例如调用x
和y
)会是引用形式。但是列表还保持着它的形状。这仍然是一个键为: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..end
和do: …
是一样的,所以当我们调用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.