[elixir! #0005] [译] 理解Elixir中的宏——part.3 深入AST by Saša Jurić
是时候继续探索Elixir中的宏了。上一次我讲了一些微观理论,今天,我将会进入一个较少提到的领域,并讨论Elixir AST的一些细节。
跟踪函数调用
目前为止,你只见到了基础的宏,它们得到AST片段,然后将其结合起来,周围加上一些模板。我们没有分析或解析输入的AST,所以这可能是最干净(或者说最少hack技巧)的编写宏的方法,得到的会是容易理解的简单的宏。
然后,有时我们需要解析输入的AST片段以获取某些信息。一个简单的例子是ExUnit
的断言。例如,表达式assert 1+1 == 2+2
会出现这个错误:
Assertion with == failed code: 1+1 == 2+2 lhs: 1 rhs: 2
宏assert
接收了整个表达式1+1 == 2+2
,然后从中分出独立的表达式用来比较,如果整个表达式返回false,则打印它们对应的结果。所以,宏的代码必须想办法将输入的AST分解为几个部分并分别计算子表达式。
更多时候,我们调用了更复杂的AST变换。例如, 你可以借助ExActor这样写:
defcast inc(x), state: state, do: new_state(state + x)
它会被转换成差不多这样:
def inc(pid, x) do :gen_server.cast(pid, {:inc, x}) end def handle_cast({:inc, x}, state) do {:noreply, state+x} end
和assert
一样,宏defcast
需要深入输入的AST片段,并找出每个子片段(例如,函数名,每个参数)。然后,ExActor执行一个精巧的变换,将各个部分重组成一个更加复杂的代码。
今天,我将想你展示构建这类宏的基础技术,我也会在之后的文章中将变换做得更复杂。但在此之前,我要请你认真考虑一下:你的代码是否有有必要基于宏。尽管宏十分强大,但也有缺点。
首先,就像之前我们看到的那样,比起那些“纯”的运行时抽象,宏的代码会很快地变得非常多。你可以依赖没有文档格式的AST来快速完成许多嵌套的quote
/unquoted
调用,以及奇怪的模式匹配。
此外,宏的滥用可能使你的客户端代码极其难懂,因为它将依赖于自定义的非标准习语(例如ExActor的defcast)。这使得理解代码和了解底层究竟发生了什么变得更加困难。
反过来,宏在删除模板方面非常管用(例如ExActor的例子所演示的),而且宏有权访问那些在运行时不可用的信息(正如你应该从assert
例子中看到的)。最后,由于它们在编译期间运行,宏可以通过将计算移动到编译时来优化一些代码。
因此,肯定会有适合使用宏的情形,你不必害怕使用它们。但你不应该只是为了获取一些可爱的DSL语法而使用宏。在考虑宏之前,你应该先考虑你的问题是否可以依赖于“标准”的语言抽象,例如函数,模块和协议,在运行时有效解决。
探索AST结构
目前,关于AST结构的文档不多。然而,在shell会话中可以很简单地探索和使用AST,我通常就是这样探索AST格式的。
例如,这是一个被引用了的变量:
iex(1)> quote do my_var end {:my_var, [], Elixir}
这里,第一个元素代表变量的名称。第二个元素是上下文关键字列表,它包含了该AST片段的元数据(例如导入和别名)。通常你不会对上下文数据感兴趣。第三个元素通常代表引用发生的模块,同时也用于确保引用变量的卫生。如果该元素为nil
,则该标识符是不卫生的。
一个简单的表达式看起来包含了许多:
iex(2)> quote do a+b end {:+, [context: Elixir, import: Kernel], [{:a, [], Elixir}, {:b, [], Elixir}]}
看起来可能很可怕,但是如果展示更高层次的模式,就很容易理解了:
{:+, context, [ast_for_a, ast_for_b]}
在我们的例子中,ast_for_a
和ast_for_b
遵循着你之前所看到的变量的形状(如{:a, [], Elixir}
)。一般,引用的参数可以是任意复杂的,因为它们描述了每个参数的表达式。事实上,AST是一个简单引用的表达式的深层结构,就像我给你展示的这样。
让我们来看一个函数调用:
iex(3)> quote do div(5,4) end {:div, [context: Elixir, import: Kernel], [5, 4]}
这类似于引用+
操作,我们知道+
实际上是一个函数。事实上,所有二进制运算符都会像函数调用一样被引用。
最后,让我们来看一个引用了的函数定义:
iex(4)> quote do def my_fun(arg1, arg2), do: :ok end {:def, [context: Elixir, import: Kernel], [{:my_fun, [context: Elixir], [{:arg1, [], Elixir}, {:arg2, [], Elixir}]}, [do: :ok]]}
看起来有点吓人,但可以通过只看重要的部分来简化它。事实上,这种深层结构相当于:
{:def, context, [fun_call, [do: body]]}
fun_call
是一个函数调用的结构(你看过的那样)。
如你所见,AST背后通常有一些原因和意义。我不会在这里写出所有AST的形状,但会在iex
中尝试你感兴趣的简单的格式来探索AST。这是一个反向工程,但不是火箭科学。
写断言宏
为了快速演示,让我们来编写一个简化版本的assert
宏。这是一个有趣的宏,因为它从字面上重新解释了比较运算符的意义。通常,当你写a == b
时,你会得到一个布尔值。但将此表达式赋给assert
宏时,如果表达式计算结果为false
,就会输出详细的结果。
我将从简单的部分开始,首先在宏里只支持==
运算符。想一下,当我们调用assert expected == required
,等同于调用assert(expect == required)
,这意味着我们的宏接收到一个表示比较的引用片段。让我们来探索这个比较的AST结构:
iex(1)> quote do 1 == 2 end {:==, [context: Elixir, import: Kernel], [1, 2]} iex(2)> quote do a == b end {:==, [context: Elixir, import: Kernel], [{:a, [], Elixir}, {:b, [], Elixir}]}
所以我们的结构本质上是{:==, context, [quoted_lhs, quoted_rhs]}
。如果你记住了前面章节中所示的例子,那么就不会感到意外,因为我提到过二进制运算符是作为二参数函数被引用。
知道了AST的形状,编写宏会相对简单:
defmodule Assertions do defmacro assert({:==, _, [lhs, rhs]} = expr) do quote do left = unquote(lhs) right = unquote(rhs) result = (left == right) unless result do IO.puts "Assertion with == failed" IO.puts "code: #{unquote(Macro.to_string(expr))}" IO.puts "lhs: #{left}" IO.puts "rhs: #{right}" end result end end end
第一件有趣的事发生在第二行。注意我们是如何模式匹配输入表达式的,期望它去符合一些结构。这是完全正常的,因为宏也是函数,所以你可以依赖模式匹配,guard语句,甚至是多个从句的宏。在我们的例子中,我们依靠模式匹配将比较表达式的每个(引用的)一侧转换为相应的变量。
然后,在引用的代码中,我们重新解释了==
操作,通过分别计算左侧和右侧(第4行和第5行),以及整个的结果(第7行)。最后,如果结果是false,我们将打印详细信息(第9到14行)。
来试一下:
iex(1)> defmodule Assertions do ... end iex(2)> import Assertions iex(3)> assert 1+1 == 2+2 Assertion with == failed code: 1 + 1 == 2 + 2 lhs: 2 rhs: 4
代码通用化
使代码适用于其它运算符并不难:
defmodule Assertions do defmacro assert({operator, _, [lhs, rhs]} = expr) when operator in [:==, :<, :>, :<=, :>=, :===, :=~, :!==, :!=, :in] do quote do left = unquote(lhs) right = unquote(rhs) result = unquote(operator)(left, right) unless result do IO.puts "Assertion with #{unquote(operator)} failed" IO.puts "code: #{unquote(Macro.to_string(expr))}" IO.puts "lhs: #{left}" IO.puts "rhs: #{right}" end result end end end
这里只有一点点变化。首先,在模式匹配中,硬编码:==
被变量operator
取代了(第2行)。
我还引入(实际上,是从Elixir源代码中复制粘贴了)guard语句指定了宏能处理的运算符集(第3行)。这个检查有一个特殊原因。还记得我之前提到的,引用a + b
(或任何其它的二进制操作)的形状等同于引用fun(a, b)
。因此,没有这些guard语句,任何双参数的函数调用都会在我们的宏中结束,这可能是我们不想要的。使用这个guard语句能将输入限制在已知的二进制运算符中。
有趣的事情发生在第9行。在这里我使用了unquote(operator)(left, right)
来对操作符进行简单的泛型分派。你可能认为我可以使用left unquote(operator) right
来替代,但它并不能运作。原因是operator
变量保存的是一个原子(如:==
)。因此,这个天真的引用会产生left :== right
,这甚至不符合Elixir语法。
记住,在引用时,我们不组装字符串,而是AST片段。所以,当我们想生成一个二进制操作代码时,我们需要注入一个正确的AST,它(如前所述)与双参数的函数调用相同。因此,我们可以简单地生成函数调用unquote(operator)(left, right)
。
这一点讲完了,今天的这一章也该结束了。它有点短,但略微复杂些。下一章,我将深入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.