[elixir! #0006] [译] 理解Elixir中的宏——part.4 更进一步 by Saša Jurić

在上一章中,我们展示了一些分析和处理输入的AST的基本方法。今天我们来看一些更复杂的AST转换。主要是重复一些已经解释过的技术。目的是表明,深入AST并不是很难,虽然结果的代码会很容易地变得相当复杂并有点hacky。

跟踪函数调用

在本文中,我们将创建一个deftraceable宏,它使我们能够定义可跟踪的函数。可跟踪函数的工作方式与正常函数相同,但是每当我们调用它时,都会打印出调试信息。这是它的作用:

defmodule Test do
  import Tracer

  deftraceable my_fun(a,b) do
    a/b
  end
end

Test.my_fun(6,2)

# => test.ex(line 4) Test.my_fun(6,2) = 3

这个例子当然是虚构的。你不需要设计这样的宏,因为Erlang已经有非常强大的跟踪功能,而且有一个Elixir包装可用。然而,这个例子很有趣,因为它需要一些更深入的AST转换和技术。

在开始之前,我要再提一次,你应该仔细考虑你是否真的需要这样的结构。例如deftraceable这样的宏引入了一个每个代码维护者都需要了解的东西。看着代码,它背后发生的事不是显而易见的。如果每个人都设计这样的结构,每个Elixir项目都会很快地变成自定义语言的大锅汤。当代码主要依赖于复杂的宏时,即使对于有经验的开发人员,也很难理解基本代码的流程。

但是在适合使用宏的情况下,你不应该仅仅因为有人声称宏是不好的,就不适用它。例如,如果在Erlang中没有跟踪功能,我们就需要设计一些宏来帮助我们(实际上不需要类似上述的例子,但那是另外一个话题),否则我们的代码就会有大量的重复模板。

在我看来,模板是不好的,因为代码中有了太多形式化的噪音,因此更难阅读和理解。宏有助于减少形式,但在使用宏之前,请先考虑是否可以使用运行时结构(函数,模块,协议)来解决重复。

看完这个长长的免责声明,让我们开始写deftraceable吧。首先,手动生成对应的代码。

让我们回忆一下用法:

deftraceable my_fun(a,b) do
  a/b
end

生成的代码如下所示:

def my_fun(a, b) do
  file = __ENV__.file
  line = __ENV__.line
  module = __ENV__.module
  function_name = "my_fun"
  passed_args = [a,b] |> Enum.map(&inspect/1) |> Enum.join(",")

  result = a/b

  loc = "#{file}(line #{line})"
  call = "#{module}.#{function_name}(#{passed_args}) = #{inspect result}"
  IO.puts "#{loc} #{call}"

  result
end

想法很简单。我们从编译器环境中获取各种数据,然后计算结果,最后将所有内容打印到屏幕上。

代码依赖于__ENV__特殊形式,可以用于在最终AST中注入所有类型的编译时信息(例如行号和文件)。__ENV__是一个结构,每当你在代码中使用它,它将在编译时扩展到适当的值。因此,无论我们在哪里写__ENV__.file,结果都是包含了文件名的(二进制)字符串常量。

现在我们需要动态构建这个代码。让我们来看看基本的大纲:

defmacro deftraceable(??) do
  quote do
    def unquote(head) do
      file = __ENV__.file
      line = __ENV__.line
      module = __ENV__.module
      function_name = ??
      passed_args = ?? |> Enum.map(&inspect/1) |> Enum.join(",")

      result = ??

      loc = "#{file}(line #{line})"
      call = "#{module}.#{function_name}(#{passed_args}) = #{inspect result}"
      IO.puts "#{loc} #{call}"

      result
    end
  end
end

这里我们在需要动态注入AST片段的地方放置问号(??),基于输入参数。特别是,我们必须从传递的参数中推导出函数名,参数名和函数体。

现在,当我们调用宏deftraceable my_fun(…) do … end,宏接收两个参数——函数头(函数名和参数列表)和包含函数体的关键字列表。。这两个当然都是被引用的。

我是如何知道的?其实我不知道。我一般通过尝试和错误获得的这些知识。基本上,我从定义一个宏开始:

defmacro deftraceable(arg1) do
  IO.inspect arg1
  nil
end

然后我尝试从一些测试模块或shell中调用宏。我将通过向宏定义中添加另一个参数来测试。一旦我得到结果,我会试图找出参数的表示是什么,然后开始构建宏。

宏结束处的nil确保我们不生成任何东西(我们生成的nil通常与调用者代码无关)。这允许我进一步构建片段而不注入代码。我通常依靠IO.inspectMacro.to_string/1来验证中间结果,一旦我满意了,我会删除nil部分,看看是否能工作。

此时deftraceable接收函数头和身体。函数头将是一个我们之前描述的格式的AST片段({function_name, context, [arg1, arg2, …])。

所以接下来我们需要:

  • 从引用的头中提取函数名和参数

  • 将这些值注入我们从宏返回的AST中

  • 将函数体注入同一个AST

  • 打印跟踪信息

我们可以使用模式匹配从这个AST片段中提取函数名和参数,但这里已经有一个helperMacro.decompose_call/1的功能正是这样。做完这些步骤,宏的最终版本如下所示:

defmodule Tracer do
  defmacro deftraceable(head, body) do
    # Extract function name and arguments
    {fun_name, args_ast} = Macro.decompose_call(head)

    quote do
      def unquote(head) do
        file = __ENV__.file
        line = __ENV__.line
        module = __ENV__.module

        # Inject function name and arguments into AST
        function_name = unquote(fun_name)
        passed_args = unquote(args_ast) |> Enum.map(&inspect/1) |> Enum.join(",")

        # Inject function body into the AST
        result = unquote(body[:do])

        # Print trace info"
        loc = "#{file}(line #{line})"
        call = "#{module}.#{function_name}(#{passed_args}) = #{inspect result}"
        IO.puts "#{loc} #{call}"

        result
      end
    end
  end
end

让我们来试试:

iex(1)> defmodule Tracer do ... end

iex(2)> defmodule Test do
          import Tracer

          deftraceable my_fun(a,b) do
            a/b
          end
        end

iex(3)> Test.my_fun(10,5)
iex(line 4) Test.my_fun(10,5) = 2.0   # trace output
2.0

它似乎可以工作。然而,我应该立刻指出这个实现中的问题:

  • 宏没有很好地处理guard

  • 模式匹配参数不会总是有效(例如,当使用 _ 去匹配任何值)

  • 当这模块中直接动态生成代码时,宏不工作

我将逐一解释每个问题,从guard语句开始,剩下的问题将在后面的文章中讨论。

掌控guards

所有与deftraceable有关的问题源于我们对输入的AST做出的一些假设。这是一个危险的领域,我们必须把所有情形都覆盖到。

例如,宏假定头部只包含名称和参数列表。因此,如果我们定义一个带有guard语句的可跟踪函数,deftraceable将不工作:

deftraceable my_fun(a,b) when a < b do
  a/b
end

在这种情况下,我们的头(宏的第一个参数)也将包含guard语句的信息,并且不能被Macro.decompose_call/1所解析。解决办法是检测这种情形,并以特殊方式处理它。

首先,让我们了解这个头是如何被引用的:

iex(1)> quote do my_fun(a,b) when a < b end
{:when, [],
 [{:my_fun, [], [{:a, [], Elixir}, {:b, [], Elixir}]},
  {:<, [context: Elixir, import: Kernel],
   [{:a, [], Elixir}, {:b, [], Elixir}]}]}

因此,本质上,我们的guard语句头具有{:when, _, [name_and_args, …]}的形状。我们可以依靠它来使用模式匹配来提取名称和参数:

defmodule Tracer do
  ...
  defp name_and_args({:when, _, [short_head | _]}) do
    name_and_args(short_head)
  end

  defp name_and_args(short_head) do
    Macro.decompose_call(short_head)
  end
  ...

当然,我们需要从宏中调用此函数:

defmodule Tracer do
  ...
  defmacro deftraceable(head, body) do
    {fun_name, args_ast} = name_and_args(head)

    ... # unchanged
  end
  ...
end

如你所见,可以定义更多的私有函数并从你的宏中调用它们。毕竟,宏只是一个函数,当它被调用时,包含的模块已经被编译并加载到了编译器的VM中(否则,宏不能运行)。

以下是宏的完整版本:

defmodule Tracer do
  defmacro deftraceable(head, body) do
    {fun_name, args_ast} = name_and_args(head)

    quote do
      def unquote(head) do
        file = __ENV__.file
        line = __ENV__.line
        module = __ENV__.module

        function_name = unquote(fun_name)
        passed_args = unquote(args_ast) |> Enum.map(&inspect/1) |> Enum.join(",")

        result = unquote(body[:do])

        loc = "#{file}(line #{line})"
        call = "#{module}.#{function_name}(#{passed_args}) = #{inspect result}"
        IO.puts "#{loc} #{call}"

        result
      end
    end
  end

  defp name_and_args({:when, _, [short_head | _]}) do
    name_and_args(short_head)
  end

  defp name_and_args(short_head) do
    Macro.decompose_call(short_head)
  end
end

让我们来试一下:

iex(1)> defmodule Tracer do ... end

iex(2)> defmodule Test do
          import Tracer

          deftraceable my_fun(a,b) when a<b do
            a/b
          end

          deftraceable my_fun(a,b) do
            a/b
          end
        end

iex(3)> Test.my_fun(5,10)
iex(line 4) Test.my_fun(10,5) = 2.0
2.0

iex(4)> Test.my_fun(10, 5)
iex(line 7) Test.my_fun(10,5) = 2.0

这个练习的要点是说明可以从输入的AST中推导出一些东西。在这个例子中,我们设法检测和处理一个函数guard。显然,代码变得更加复杂,因为它依赖于AST的内部结构。在这种情况下,代码依旧比较简单,但你将在后面的文章中看到我是如何解决deftraceable剩余的问题的,事情可能很快变得凌乱了。

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.

相关推荐