[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.inspect
和Macro.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.