TIL 笔记:Elixir 元编程基石——用 quote 宏访问代码的 AST 内部表示

原创2026-10-03 17:17:48614 阅读
文章标签:文档教程知识库

TIL 笔记:Elixir 元编程基石——用 quote 宏访问代码的 AST 内部表示

本篇技术指南以 elixir/expose-internal-representation.md 为骨架展开,讲解 Elixir 元编程(Metaprogramming)最核心的一环:通过 quote 宏把一段源代码"降级"成普通数据——即抽象语法树(AST),从而让程序能够读懂、分析乃至改写自己的代码。读完本文,你将掌握 Elixir AST 的三元组结构、quote 在 IEx 中的实操方法,以及它与 unquote、defmacro 组合构建 DSL 的完整链路。

为什么需要"查看代码的内部表示"

Elixir 是一门对元编程支持极强的语言。所谓元编程,就是"写代码的代码"——让程序在编译期或运行期生成、变换另一段代码。要做到这一点,语言必须先回答一个根本问题:

代码在计算机眼里长什么样?

答案是:代码不是一堆字符,而是一棵结构化的树,即抽象语法树(AST)。在 Elixir 中,这棵树的节点由 map 和 keyword list 这两种基础数据结构组成,因此 AST 本身既是可读的 Elixir 数据,又是可被模式匹配、可被遍历、可被重新组合的普通值。quote 宏就是打开这扇门的钥匙:它把一段代码的 AST 原样"转录"出来展示给你看。

quote 宏:把代码变成数据

quote 是 Elixir 的特殊形式(Special Form),基本用法是 quote do ... end,在 IEx 中可以直接观察它的输出。原文档给出了两个最经典的示例:

> quote do: 2 * 2
{:*, [context: Elixir, import: Kernel], [2, 2]}
> quote do: 2 * 2 == 4
{:==, [context: Elixir, import: Kernel],
 [{:*, [context: Elixir, import: Kernel], [2, 2]}, 4]}

注意第一个例子:quote do: 2 * 2 并没有返回数字 4,而是返回了一个三元组(three-element tuple),这就是乘法表达式 2 * 2 在 Elixir 虚拟机中的真实面貌。第二个例子更说明问题:整个表达式 2 * 2 == 4 被转录成一个大的三元组,其中第三个位置(参数列表)里又嵌套着 {:*, ...} 这个子三元组——嵌套的表达式对应嵌套的 AST,树形结构一目了然。

这套笔记所属的 TIL 仓库中还有一批与元编程和数据结构相关的姊妹篇,例如 elixir/dynamically-generating-atoms.md 演示了如何通过字符串插值动态生成 :some_atom_1 之类的原子,elixir/pattern-matching-in-anonymous-functions.md 与 elixir/defining-multiple-clauses-in-an-anonymous-function.md 展示了 {:ok, result} / {:error, x} 这类元组模式匹配惯用法。它们与 quote 的 AST 三元组共同构成了理解 Elixir"数据即代码"思维的基础。

拆解 AST 的三元组结构

Elixir 的 AST 节点遵循统一的三元组约定:

{ 操作符/调用名(原子), 元数据(keyword list), 参数列表(list) }

以 {:*, [context: Elixir, import: Kernel], [2, 2]} 为例,逐项解读:

位置 内容 含义
第 1 项 :* 操作符原子,对应 Elixir 的乘法操作符
第 2 项 [context: Elixir, import: Kernel] 元数据关键字列表,记录该节点的上下文信息
第 3 项 [2, 2] 操作数列表,即被相乘的两个字面量

元数据里的 context 与 import

第二项元数据是理解"卫生宏(hygiene)"机制的关键:

  • context: Elixir:标明该代码片段处于 Elixir 的默认上下文,宏展开时变量与原子不会与调用方冲突,这正是 Elixir 宏与 C 预处理器宏的本质区别。
  • import: Kernel:表示该操作符来自 Kernel 模块的导入,IEx 会话中默认可见 + - * / == 等操作符都源于此。

嵌套表达式 = 嵌套元组

2 * 2 == 4 的 AST 展示得尤为清楚:

{:==, [context: Elixir, import: Kernel],
 [{:*, [context: Elixir, import: Kernel], [2, 2]}, 4]}

最外层节点是相等比较 :==,其参数列表 [ {:*, ...}, 4 ] 中,第一个参数又完整嵌入了 2 * 2 的 AST。遍历 AST 就是在递归遍历这棵元组树,这也是后续实现代码变换(如代码格式化、Lint、自动重构)的统一入口。

变量、调用与字面量的不同形态

AST 的统一性建立在"万物皆表达式"之上,但不同语法形态的节点略有差异:

  • 字面量(数字、字符串、原子等):直接以自身作为节点,例如上面参数列表里的 2 与 4;
  • 变量:以 {变量名原子, 元数据, 上下文} 的三元组表示,例如 quote do: x 会得到 {:x, [], Elixir};
  • 函数调用:形如 {{:函数名, 元数据, nil}, 调用元数据, [参数...]}。

这些形态都可以在 IEx 中即时验证,无需任何额外依赖。

在 IEx 中亲手实验

理解 AST 最快的途径是动手。打开 iex,逐条执行并对照输出:

iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}

iex> quote do: x = 5
{:=, [context: Elixir, import: Kernel], [{:x, [], Elixir}, 5]}

iex> quote do: String.upcase("hi")
{{:upcase, [context: Elixir, import: Kernel], nil},
 [context: Elixir, import: Kernel], ["hi"]}

反向验证同样方便:把 AST 还原回源码或直接求值,用到的都是 Elixir 公开的标准库函数。

# 把 AST 还原为可读源码
iex> Macro.to_string(quote do: 2 * 2 == 4)
"2 * 2 == 4"

# 对 AST 求值
iex> Code.eval_quoted(quote do: 2 * 2)
{4, []}

Macro.to_string/2 与 Code.eval_quoted/3 是标准库 Macro 与 Code 模块提供的公开 API,前者用于将 AST 序列化回源码文本(是代码格式化工具的基础),后者用于在编译期之后对已构建的 AST 求值。配合原文档中的示例,你可以在 IEx 中完成"代码 → AST → 源码"的完整闭环,这对理解宏的展开过程极有帮助。

从观察 AST 到编写宏:unquote 与 defmacro

只观察 AST 还不够,元编程的真正威力在于构建新的 AST。quote 的逆操作是 unquote:quote 负责"把代码包成数据",unquote 负责在数据里"解包并注入"一个运行时值或另一段 AST。两者配合 defmacro 即可定义自定义宏。

以一个极简的"断言相等"宏为例:

defmodule MyAssert do
  defmacro assert_equal(left, right) do
    quote do
      if unquote(left) == unquote(right) do
        :ok
      else
        {:error, "expected #{unquote(left)}, got #{unquote(right)}"}
      end
    end
  end
end

这里 unquote(left) 会把调用时传入的表达式 AST 拼接到 quote 生成的骨架中,最终组装出一棵完整的新 AST,再交由编译器展开执行。宏的本质就是"输入 AST、输出 AST"的纯函数,而 quote/unquote 是这个函数的核心工具。

类似手法在 Elixir 标准库中随处可见,最典型的例子是 Kernel 模块里的 unless 宏——它的实现就是"把 if 的条件取反":内部先 quote 出 if 的结构,再通过 unquote 注入一个 not 包装后的条件表达式。理解这一层后,很多"框架魔法"(如 Ecto 的查询表达式、Phoenix 的路由宏)就不再神秘。

quote 的常用选项

quote 还支持若干选项,用于精确控制 AST 的生成行为,常见的有:

选项 作用
:line 指定生成的 AST 节点所在行号,影响报错定位
:hygiene 控制变量与原子的卫生性(默认启用,防止宏污染调用方作用域)
:bind_quoted 通过绑定上下文一次性注入多个变量,避免重复 unquote,也是官方推荐的性能优化方式
:location 决定元数据中是否携带 :line / :file 等位置信息
:generated 标记节点为"由宏生成",抑制部分编译告警
:context 设置节点上下文,配合宏卫生机制使用

这些选项在编译期改变 AST 的元数据与变量解析方式,初学时无需全部掌握,但知道它们的存在能帮你更快读懂框架源码。

在 TIL 仓库中继续探索

本篇笔记是 TIL 仓库 Elixir 分类 下的知识点之一。仓库中围绕"模式匹配 + 数据结构"的笔记与本文互为印证:

它们共同勾勒出一条从"数据结构"到"元编程"的学习路径。当你能够熟练地 quote 一段代码、读懂输出中的三元组、并用模式匹配拆分它们时,你就已经掌握了 Elixir 宏体系最重要的底层直觉。

登录后查看全文
til