伤心客 发表于 2024-5-16 08:42:24

(译) 明白 Elixir 中的宏 Macro, 第三部分:深入明白 AST

Elixir Macros 系列文章译文

[*] (译) Understanding Elixir Macros, Part 1 Basics
[*] (译) Understanding Elixir Macros, Part 2 - Macro Theory
[*] (译) Understanding Elixir Macros, Part 3 - Getting into the AST
[*] (译) Understanding Elixir Macros, Part 4 - Diving Deeper
[*] (译) Understanding Elixir Macros, Part 5 - Reshaping the AST
[*] (译) Understanding Elixir Macros, Part 6 - In-place Code Generation
原文 GitHub 仓库, 作者: Saša Jurić.
是时候继承探索 Elixir 的宏了. 上次我介绍了一些关于宏的根本原理, 本日, 我将进入一个较少谈及的领域, 并讨论Elixir AST 的一些细节.
跟踪函数调用

到目前为止, 你只看到了继承输入 AST 片段并将它们组合在一起的底子宏, 并在输入片段周围或之间添加了一些额外的样板代码. 由于我们不分析或分析输入的 AST, 这可能是最干净(或最不 hackiest)的宏编写风格, 这样的宏相当简单且轻易明白.
然而, 偶然候我们需要分析输入的 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 会执行一个精巧的变动, 将各个部分重组成一个更加复杂的代码.
本日, 我将想你展示构建这类宏的底子技术, 我也会在之后的文章中会将变动做得更复杂. 但在此之前, 我要请你认真考虑一下你的代码是否有有必要基于宏. 尽管宏十分强大, 但也有缺点.
首先, 就像之前我们看到的那样, 比起那些 "平凡" 的运行时抽象 (函数, 模块, 协议), 宏的代码会很快地变得非常多. 你可以依赖 undocumented format (译注: 缺少文档表明, 寓意代码极其难以明白) 的 AST 来快速完成许多嵌套的 quote/unquoted 调用, 以及奇怪的模式匹配.
此外, 宏的滥用可能使你的客户端代码 (译注: 使用宏的代码) 极其难明, 因为它将依赖于自定义的非标准风俗用法(比方 ExActor 的 defcast). 这使得明白代码和了解底层究竟发生了什么变得更加困难.
从好的方面来看, 宏在删除样板代码时非常有用(正如 ExActor 示例所展示的那样), 并且具有访问运行时不可用的信息的本领(正如您应该从 assert 示例中看到的那样).最后, 由于宏在编译期间运行, 因此可以通过将计算转移到编译时来优化一些代码.
因此, 肯定会有适合宏的景象, 您不应该害怕使用它们. 但是, 您不应该仅仅为了获得一些可爱的 dsl 式语法而选择宏. 在使用宏之前, 应该考虑是否可以依赖“标准”语言抽象(如函数、模块和协议)在运行时有效地解决问题.
探索 AST 结构

目前, 关于 AST 结构的文档不多. 然而, 在 shell 会话中可以很简单地探索和使用 AST, 我通常就是这样探索 AST 结构的.
比方, 这里有一个关于变量的 quoted
iex(1)> quote do my_var end
{:my_var, , Elixir}在这里, 第一个元素代表变量的名称;第二个元素是上下文 Keyword 列表, 它包罗了该 AST 片段的元数据(比方 imports 和 aliases). 通常你不会对上下文数据感兴趣;第三个元素通常代表 quoted 发生的模块, 同时也用于确保 quoted 变量的 hygienic. 如果该元素为 nil, 则该标识符是不 hygienic 的.
一个简单的表达式看起来包罗了许多东西:
iex(2)> quote do a+b end
{:+, ,
[{:a, , Elixir}, {:b, , Elixir}]}看起来可能很复杂, 但是如果我向你展示更高层次的表达模式, 就很轻易明白了:
{:+, context, }在我们的例子中, ast_for_a 和 ast_fot_b 遵循着你之前所看到的变量的外形(如 {:a, , Elixir}). 一般, quoted 的参数可以是任意复杂的, 因为它们描述了每个参数的表达式. 究竟上, Elixir AST 是一个简单 quoted expression 的深层结构, 就像我给你展示的那样.
让我们看一个关于函数调用的例子:
iex(3)> quote do div(5,4) end
{:div, , }这雷同于 quoted + 的操纵, 我们知道 + 实际上是一个函数. 究竟上, 所有二进制运算符都会像函数调用一样被 quoted.
最后, 让我们来看一个被 quoted 的函数定义:
iex(4)> quote do def my_fun(arg1, arg2), do: :ok end
{:def, ,
[
   {:my_fun, ,
    [
      {:arg1, , Elixir},
      {:arg2, , Elixir}
    ]},
   
]}看起来有点吓人, 但可以只看紧张的部分来简化它. 究竟上, 这种深层结构相当于:
{:def, context, ]}fun_call 是一个函数调用的结构(正如之前你看过的那样).
如你所见, AST 背后通常有一些逻辑和意义. 我不会在这里写出所有 AST 的外形, 但会在 iex 中尝试你感兴趣的简单的结构来探索 AST. 这是一个逆向工程, 但不是火箭科学.
写一个 assert 宏

为了快速演示, 让我们编写一个简化版的 assert 宏. 这是一个有趣的宏, 因为它重新定义了比较操纵符的含义. 通常, 当你写下 a == b 表达式时, 你会得到一个布尔结果. 但是, 当将此表达式给 assert 宏时, 如果表达式的计算结果为 false, 则会打印具体的输出.
我将从简单的部分开始, 首先在宏里只支持 == 运算符. 可以知道, 我们调用 assert expected == required 时, 等同于调用 assert(expect == required), 这意味着我们的宏吸收到一个表现比较的引用片段. 让我们来探索这个比较表达式的 AST 结果:
iex(1)> quote do 1 == 2 end
{:==, , }

iex(2)> quote do a == b end
{:==, ,
[{:a, , Elixir}, {:b, , Elixir}]}所以我们的结构本质上是 {:==, context, }. 如果你记住了前几个系列中所演示的例子, 那么就不会感到意外, 因为我提到过二进制运算符是作为 2 个参数的函数被 quoted 的.
知道了 AST 的外形, 实现这个宏就很简单:
defmodule Assertions do
defmacro assert({:==, _, } = 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第一个有趣的事变发生在第 2 行. 注意我们是如何对输入表达式进行模式匹配的, 渴望它符合某种结构. 这完全没问题, 因为宏是函数, 这意味着您可以依赖于模式匹配、guards(守卫), 乃至有多子句宏. 在我们的例子中, 我们依赖模式匹配将(被 quoted 的)比较表达式的每一边带入相应的变量.
然后, 在 quoted 的代码中, 我们通过分别计算左边和右边重新表明 == 操纵(第 4 行和第 5 行), 然后是整个结果(第 7 行). 最后, 如果结果为假, 我们打印具体信息(第 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
false将代码实现通用化

将之前的代码用到其他的运算操纵符并不困难:
defmodule Assertions do
defmacro assert({operator, _, } = 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这里只有一点点变化. 首先, 在模式匹配中, 硬编码(hard code) :== 被变量 operator 取代了(第 2 行).
我还引入(实际上, 是从 Elixir 源代码中复制粘贴了)guard 语句指定了宏能处理的运算符集(第 3 行). 这个检查有一个特别缘故原由. 还记得我之前提到的, quoted a + b(或任何其它的二进制操纵)的外形等同于引用 fun(a, b). 因此, 没有这些 guard 语句, 任何双参数的函数调用都会在我们的宏中结束, 这可能是我们不想要的. 使用这个 guard 语句能将输入限制在已知的二进制运算符中.
有趣的事变发生在第 9 行. 在这里我使用了 unquote(operator)(left, right) 来对操纵符进行简单的泛型分派. 你可能认为我可以使用 left unquote(operator) right 来替代, 但它并不能运算. 缘故原由是 operator 变量保存的是一个原子(如:==). 因此, 这个灵活的 quoted 会产生 left :== right, 这乃至不符合 Elixir 的语法规定.
记住, 在 quote 时, 我们不组装字符串, 而是组装 AST 片段. 所以, 当我们想天生一个二进制操纵代码时, 我们需要注入一个正确的 AST, 它(如前所述)与双参数的函数调用雷同. 因此, 我们可以简单地使用函数调用的方式 unquote(operator)(left, right).
这一点讲完了, 本日的这一章也该结束了. 它有点短, 但略微复杂些. 下一章 《(译) Understanding Elixir Macros, Part 4 - Diving Deeper》, 我将深入 AST 分析的话题.
原文: https://www.theerlangelist.com/article/macros_3
本文由博客群发一文多发等运营工具平台 OpenWrite 发布

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: (译) 明白 Elixir 中的宏 Macro, 第三部分:深入明白 AST