2

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


Ljzn
399 声望102 粉丝

网络安全;函数式编程;数字货币;人工智能