[elixir! #0005] [译] 理解Elixir中的宏——part.3 深入AST by Saša Jurić

坚强00CSDN 2019-06-20

是时候继续探索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_aast_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.

相关推荐

LustrousElixir / 0评论 2013-02-25

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