TIL 笔记:Elixir 元编程基石——用 quote 宏访问代码的 AST 内部表示
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 分类 下的知识点之一。仓库中围绕"模式匹配 + 数据结构"的笔记与本文互为印证:
- elixir/pattern-matching-in-anonymous-functions.md:对
{:ok, result}/:error等元组做模式匹配——这正是解构 AST 元组时使用的同一套语法; - elixir/defining-multiple-clauses-in-an-anonymous-function.md:按参数形状分派不同行为,在宏展开器中常用来区分不同 AST 节点形态;
- elixir/dynamically-generating-atoms.md:动态生成原子,与
quote中原子即节点名的设计相呼应。
它们共同勾勒出一条从"数据结构"到"元编程"的学习路径。当你能够熟练地 quote 一段代码、读懂输出中的三元组、并用模式匹配拆分它们时,你就已经掌握了 Elixir 宏体系最重要的底层直觉。