(译) 明确 Elixir 中的宏 Macro, 第四部门:深入化

打印 上一主题 下一主题

主题 674|帖子 674|积分 2022

Elixir Macros 系列文章译文
在前一篇文章中, 我向你展示了分析输入 AST 并对其进行处理的一些根本方法. 今天我们将研究一些更复杂的 AST 转换. 这将重提已经解释过的技术. 如许做的目的是为了表明深入研究 AST 并不是很难的, 只管最终的结果代码很容易变得相当复杂, 而且有点黑科技(hacky).
追踪函数调用

在本文中, 我们将创建一个宏 deftraceable, 它允许我们界说可跟踪的函数. 可跟踪函数的工作方式与平凡函数一样, 但每当我们调用它时, 都会打印出调试信息. 大致思路是如许的:
  1. defmodule Test do
  2.   import Tracer
  3.   deftraceable my_fun(a,b) do
  4.     a/b
  5.   end
  6. end
  7. Test.my_fun(6,2)
  8. # => test.ex(line 4) Test.my_fun(6,2) = 3
复制代码
这个例子当然是虚构的. 你不需要设计如许的宏, 因为 Erlang 已经有非常强大的跟踪功能, 而且有一个 Elixir 包可用. 然而, 这个例子很风趣, 因为它需要一些更深层次的 AST 转换技巧.
在开始之前, 我要再提一次, 你应该仔细考虑你是否真的需要如许的结构. 例如 deftraceable 如许的宏引入了一个每个代码维护者都需要了解的东西. 看着代码, 它背后发生的事不是显而易见的. 假如每个人都设计如许的结构, 每个 Elixir 项目都会很快地变成自界说语言的大锅汤. 当代码重要依赖于复杂的宏时, 纵然对于有经验的开发人员, 纵然是有经验的开发人员也很难明确严重依赖于复杂宏的底层代码的实际流程.
但是在得当使用宏的情况下, 你不应该仅仅因为有人声称宏是不好的, 就不使用它. 例如, 假如在 Erlang 中没有跟踪功能, 我们就需要设计一些宏来帮助我们(实际上不需要类似上述的例子, 但那是另外一个话题), 否则我们的代码就会有大量重复的模板代码.
在我看来, 模板代码太多是不好的, 因为代码中有了太多形式化的噪音, 因此更难阅读和明确. 宏有助于减少这些噪声, 但在使用宏之前, 请先考虑是否可以优先使用 Elixir 内置的运行时结构(函数, 模块, 协议)来解决重复代码.
看完这个长长的免责声明, 让我们开始实现 deftraceable吧. 首先, 手动生成对应的代码.
让我们回顾下用法:
  1. deftraceable my_fun(a,b) do
  2.   a/b
  3. end
复制代码
生成的代码类似于如许:
  1. def my_fun(a, b) do
  2.   file = __ENV__.file
  3.   line = __ENV__.line
  4.   module = __ENV__.module
  5.   function_name = "my_fun"
  6.   passed_args = [a,b] |> Enum.map(&inspect/1) |> Enum.join(",")
  7.   result = a/b
  8.   loc = "#{file}(line #{line})"
  9.   call = "#{module}.#{function_name}(#{passed_args}) = #{inspect result}"
  10.   IO.puts "#{loc} #{call}"
  11.   result
  12. end
复制代码
这个想法很简单. 我们从编译器环境中获取各种数据, 然后盘算结果, 最后将全部内容打印到屏幕上.
该代码依赖于 __ENV__ 特殊形式, 可用于在最终 AST 中注入各种编译时信息(例如行号和文件). __ENV__ 是一个结构体, 每当你在代码中使用它时, 它将在编译时展开为得当的值. 因此, 只要在代码中写入 __ENV__.file. 文件生成的字节码将包罗包罗文件名的(二进制)字符串常量.
现在我们需要动态构建这个代码. 让我们来看看大概的样子(outline):
  1. defmacro deftraceable(??) do
  2.   quote do
  3.     def unquote(head) do
  4.       file = __ENV__.file
  5.       line = __ENV__.line
  6.       module = __ENV__.module
  7.       function_name = ??
  8.       passed_args = ?? |> Enum.map(&inspect/1) |> Enum.join(",")
  9.       result = ??
  10.       loc = "#{file}(line #{line})"
  11.       call = "#{module}.#{function_name}(#{passed_args}) = #{inspect result}"
  12.       IO.puts "#{loc} #{call}"
  13.       result
  14.     end
  15.   end
  16. end
复制代码
这里我们在需要基于输入参数动态注入 AST 片段的地方放置问号(??). 特殊地, 我们必须从传递的参数中推导出函数名、参数名和函数体.
现在, 当我们调用宏 deftraceable my_fun(...) do ... end, 宏接收两个参数 — 函数头(函数名和参数列表)和包罗函数体的关键字列表. 这些都是被 quote 过的.
我是怎样知道的?实在我不知道. 我一般通过不断试错来获得的这些信息. 根本上, 我从界说一个宏开始:
  1. defmacro deftraceable(arg1) do
  2.   IO.inspect arg1
  3.   nil
  4. end
复制代码
然后我尝试从一些测试模块或 shell 中调用宏. 我将通过向宏界说中添加另一个参数来测试. 一旦我得到结果, 我会试图找出参数体现什么, 然后开始构建宏.
宏竣事处的 nil 确保我们不生成任何东西(我们生成的 nil 通常与调用者代码无关). 这允许我进一步构建片段而不注入代码. 我通常依靠 IO.inspect和 Macro.to_string/1 来验证中间结果, 一旦我满足了, 我会删除 nil 部门, 看看是否能工作.
此时 deftraceable 接收函数头和身体. 函数头将是一个我们之前描述的结构的 AST 片段:
  1. {function_name, context, [arg1, arg2, ...]
复制代码
以是接下来我们需要:

  • 从 quoted 的头中提取函数名和参数
  • 将这些值注入我们的宏返回的 AST 中
  • 将函数体注入同一个 AST
  • 打印跟踪信息
我们可以使用模式匹配从这个 AST 片段中提取函数名和参数, 有一个 Macro.decompose_call/1 的辅助功能函数可以帮我们做到. 做完这些步骤, 宏的最终版本实现如下所示:
  1. defmodule Tracer do
  2.   defmacro deftraceable(head, body) do
  3.     # 提取函数名和参数
  4.     {fun_name, args_ast} = Macro.decompose_call(head)
  5.     quote do
  6.       def unquote(head) do
  7.         file = __ENV__.file
  8.         line = __ENV__.line
  9.         module = __ENV__.module
  10.         # 注入函数名和参数到 AST 中
  11.         function_name = unquote(fun_name)
  12.         passed_args = unquote(args_ast) |> Enum.map(&inspect/1) |> Enum.join(",")
  13.         # 将函数体注入到 AST
  14.         result = unquote(body[:do])
  15.         # 打印 trace 跟踪信息
  16.         loc = "#{file}(line #{line})"
  17.         call = "#{module}.#{function_name}(#{passed_args}) = #{inspect result}"
  18.         IO.puts "#{loc} #{call}"
  19.         result
  20.       end
  21.     end
  22.   end
  23. end
复制代码
让我们试一下:
  1. iex(1)> defmodule Tracer do ... end
  2. iex(2)> defmodule Test do
  3.           import Tracer
  4.           deftraceable my_fun(a,b) do
  5.             a/b
  6.           end
  7.         end
  8. iex(3)> Test.my_fun(10,5)
  9. iex(line 4) Test.my_fun(10,5) = 2.0   # trace output
  10. 2.0
复制代码
这似乎起作用了. 然而, 我应该立即指出, 这种实现存在一些题目:

  • 宏不能很好地处理带保卫(guards)的函数界说
  • 模式匹配参数并不总是有效的(例如, 当使用 _ 来匹配任何 term 时)
  • 在模块中直接动态生成代码时, 宏不起作用.
我将逐一解释这些题目, 首先从保卫(guards)开始, 其余题目留待以后的文章再讨论.
处理 guards (保卫)

全部具有可追溯性的题目都源于我们对输入 AST 做了一些事实假设. 这是一个危险的领域, 我们必须小心地涵盖全部情况.
例如, 宏假设 head 只包罗函数名称和参数列表. 因此, 假如我们想界说一个带保卫的可跟踪函数, deftraceable 将不起作用:
  1. deftraceable my_fun(a,b) when a < b do
  2.   a/b
  3. end
复制代码
在这种情况下, 我们的头部(宏的第一个参数)也将包罗保卫(guards)的信息, 并且不能被 macro .decompose_call/1 解析. 解决方案是检测这种情况, 并以一种特殊的方式处理它.
首先, 让我们来看看这个 head 是怎样被 quoted 的:
  1. iex(16)> quote do my_fun(a,b) when a < b end
  2. {:when, [],
  3. [
  4.    {:my_fun, [],
  5.     [{:a, [if_undefined: :apply], Elixir}, {:b, [if_undefined: :apply], Elixir}]},
  6.    {:<, [context: Elixir, import: Kernel],
  7.     [{:a, [if_undefined: :apply], Elixir}, {:b, [if_undefined: :apply], Elixir}]}
  8. ]}
复制代码
让我们来试验一下:
  1. defmodule Tracer do
  2.   ...
  3.   defp name_and_args({:when, _, [short_head | _]}) do
  4.     name_and_args(short_head)
  5.   end
  6.   defp name_and_args(short_head) do
  7.     Macro.decompose_call(short_head)
  8.   end
  9.   ...
复制代码
这个练习的重要目的是说明可以从输入 AST 中推断出一些东西. 在这个例子中, 我们想法检测和处理带 guards 的函数. 显然, 因为它依赖于 AST 的内部结构, 代码变得更加复杂了. 在这种情况下, 代码仍旧比较简单, 但你将在背面的文章 《(译) Understanding Elixir Macros, Part 5 - Reshaping the AST》 中看到我是怎样解决 deftraceable 宏剩余的题目的, 事情可能很快变得复杂起来了.
原文: https://www.theerlangelist.com/article/macros_4
本文由博客群发一文多发等运营工具平台 OpenWrite 发布

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

三尺非寒

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表