Elixir 的宏(macro)不是运行期的字符串替换,而是编译期的 AST 变换。它让语言本身可以被扩展:def、defmodule、use、if、|> 全都是宏。这种能力让库作者可以造出声明式 DSL,把重复的样板代码压成一行配置——Phoenix 的路由、Ecto 的 Schema、Absinthe 的类型定义都是这么来的。
代价同样真实:宏在编译期执行任意代码,出错信息会指向展开后的代码,调试难度远高于普通函数;滥用宏会让代码「不像 Elixir」。本文先把 AST 与 quote/unquote 的机制讲透,再讨论什么时候值得写宏、什么时候应该退回函数。
AST 的三元组表示
Elixir 的抽象语法树(Abstract Syntax Tree,AST)只有三种节点形态:字面量(整数、原子、字符串等,本身就是自己的 AST)、变量或函数调用的三元组 {name, metadata, args}、以及列表(表示 AST 序列)。
iex> quote do: 1 + 2
{:+, [context: Elixir, imports: [{2, Kernel}]], [1, 2]}
iex> quote do: sum(1, 2)
{:sum, [], [1, 2]}
iex> quote do: x
{:x, [], Elixir}
iex> quote do: [1, 2, 3]
[1, 2, 3]
三元组的三个位置含义固定:
| 位置 | 名称 | 内容 |
|---|---|---|
| 第一 | name | 原子,函数名或变量名 |
| 第二 | metadata | 关键字列表,行号、上下文、导入信息 |
| 第三 | args | 参数列表;变量为 nil 或模块原子 |
x 的第三位是 Elixir 而非 nil,说明它是一个变量引用;sum(1, 2) 的第三位是参数列表,说明它是一个调用。这个区别是宏处理中最关键的一条判据:
defp var?({name, _meta, ctx}) when is_atom(name) and is_atom(ctx), do: true
defp var?(_), do: false
Macro 模块提供了完整的手术工具集:
iex> ast = quote do: sum(1, 2)
iex> Macro.to_string(ast)
"sum(1, 2)"
iex> {name, _meta, args} = ast
iex> name
:sum
iex> Macro.prewalk(ast, fn node -> node end)
Macro.to_string/1 是把 AST 变回可读代码的利器,调试宏时第一时间就该打印它。Macro.prewalk/2 与 Macro.postwalk/2 是深度优先的树遍历,前者自顶向下,后者自底向上,是改写 AST 的主力。
quote 与 unquote
quote 把一段代码转成 AST,unquote 把外部值注入 AST。两者的关系是元编程的核心:
iex> x = 42
iex> ast = quote do: 1 + unquote(x)
{:+, [], [1, 42]}
iex> Code.eval_quoted(ast)
{43, []}
没有 unquote,x 会被当作变量名 :x 原样进入 AST:
iex> quote do: 1 + x
{:+, [], [1, {:x, [], Elixir}]}
三层嵌套时,unquote 的作用域逐层深入,需要 unquote(unquote(...)):
value = 10
inner = quote do: unquote(value) + 1
outer = quote do: unquote(inner) * 2
quote 有几个影响 AST 内容的选项:
| 选项 | 作用 |
|---|---|
:bind_quoted | 把绑定整体传入,自动插入 unquote,避免重复展开 |
:context | 指定变量的上下文模块,用于卫生控制 |
:location | 控制是否记录行号 |
:line | 指定行号,影响报错定位 |
:generated | 标记为生成代码,抑制部分警告 |
:unquote | 布尔值,关闭 unquote 处理 |
bind_quoted 在写宏时几乎总该用上,它让宏体更干净:
defmacro my_if(condition, do: block) do
quote bind_quoted: [condition: condition, block: block] do
case condition do
x when x in [false, nil] -> nil
_ -> block
end
end
end
宏的定义、展开与卫生
defmacro 定义的函数在编译期执行,返回值必须是 AST。展开时机是「调用点」——编译器遇到宏调用时立即展开,再把结果继续编译。
defmodule MyMacros do
defmacro double(expr) do
quote do
unquote(expr) * 2
end
end
end
defmodule UseIt do
import MyMacros
def run do
double(5)
end
end
MyMacros.double(5) 在编译 UseIt 时就变成了 5 * 2,运行时没有 double 的踪迹。这是宏与函数最本质的差别:宏没有运行时开销,但有编译时成本。
卫生(hygiene) 是 Elixir 宏最重要的安全机制。宏内部引入的变量与调用点同名变量互不干扰:
defmacro hygienic do
quote do
x = 1
x
end
end
x = 100
hygienic() # => 1,不影响外部的 x
编译器通过在 metadata 里记录 context 来区分同名变量。当宏确实需要与外层共享变量时,用 var!/2 显式打破卫生:
defmacro assign_and_return do
quote do
var!(shared) = 1
var!(shared) + 1
end
end
var!/2 是一把双刃剑:它让宏能读写调用点的变量,但也把命名冲突的风险还给了使用者。规则是只在明确约定变量名的 DSL 场景使用(例如 Ecto 查询里的 ^ 插值),普通工具宏一律保持卫生。
import 的顺序会影响宏的可见性:import MyMacros 之后调用才被识别为宏;如果宏与同名函数并存,import MyMacros, only: [double: 1] 的显式导入能避免歧义。Elixir 要求宏在使用前定义(或在同一编译单元内),否则会报 undefined macro——这与 Erlang 的 自定义 behaviour
中回调模块必须先编译是同一类约束。
用宏设计 DSL
宏最常见的用途是造 DSL。判断是否值得写宏有个简单标准:如果一段样板代码的模式固定、重复次数多、且用函数表达会丢失可读性,宏才划算。
声明式校验
先看一个不用宏的版本:
defmodule User do
def validate(params) do
with :ok <- check_present(params, :name),
:ok <- check_length(params, :name, 1, 50),
:ok <- check_format(params, :email, ~r/@/) do
:ok
end
end
end
改成宏 DSL 后,校验规则变成声明:
defmodule Validator do
defmacro __using__(_opts) do
quote do
import Validator, only: [validates: 2, validates: 3]
Module.register_attribute(__MODULE__, :rules, accumulate: true)
@before_compile Validator
end
end
defmacro validates(field, opts) do
quote do
@rules {unquote(field), unquote(Macro.escape(opts))}
end
end
defmacro __before_compile__(env) do
rules = Module.get_attribute(env.module, :rules)
checks =
Enum.map(rules, fn {field, opts} ->
quote do
def validate_field(unquote(field), value) do
Validator.check(value, unquote(Macro.escape(opts)))
end
end
end)
quote do
unquote(checks)
def validate(params) do
Enum.reduce(unquote(Enum.map(rules, &elem(&1, 0))), :ok, fn field, acc ->
case acc do
:ok -> validate_field(field, Map.get(params, field))
err -> err
end
end)
end
end
end
end
使用方只需声明:
defmodule UserValidator do
use Validator
validates :name, presence: true, length: 1..50
validates :email, presence: true, format: ~r/@/
end
这里出现了三个关键机制:__using__/1 在 use Validator 处注入代码;Module.register_attribute 以 accumulate: true 收集所有 validates 调用;@before_compile 在模块编译完成前拿到全部规则并生成最终的 validate/1。规则的收集发生在编译期,生成的函数没有任何反射开销——这是宏 DSL 相对运行期配置的核心优势。
状态机 DSL
把 gen_statem 状态机 的样板也适合宏化:
defmodule OrderStateMachine do
use StateMachine, initial: :pending
state :pending do
on :pay, to: :paid
on :cancel, to: :cancelled
end
state :paid do
on :ship, to: :shipped
end
end
StateMachine 的 __before_compile__ 会把每个 on 编译成一个 transitions/0 函数返回的 map,运行期的 transition/2 只是查表。相比手写 case 分支,声明式写法让状态图一目了然,且能在编译期校验「目标状态是否已定义」——把运行期错误提前到编译期,是宏最有价值的产出。
编译期注入:use、using 与 @before_compile
use Mod 等价于 require Mod; Mod.__using__(opts),它是宏注入的标准入口。围绕它有几个必须记住的时序点:
| 回调 | 触发时机 | 典型用途 |
|---|---|---|
__using__/1 | use 语句处立即展开 | 注入 import、属性、默认实现 |
@before_compile | 模块全部代码编译完成后 | 读取累积属性、生成最终函数 |
@after_compile | 编译产物生成后 | 校验、生成文档 |
@on_definition | 每定义一个函数时 | 检查函数命名、参数约定 |
@on_definition 常被用来做团队规范校验,例如禁止定义 handle_xxx 之外的私有函数:
defmacro __using__(_opts) do
quote do
@on_definition MyLib.Checker
end
end
def __on_definition__(env, kind, name, args, _guards, _body) do
if kind == :def and String.starts_with?(Atom.to_string(name), "debug_") do
IO.warn("debug function #{name}/#{length(args)} in #{env.module}", Macro.Env.location(env))
end
end
编译期注入也常与类型规约配合:宏生成的函数可以同时声明 @spec,让 Dialyzer 与 typespec
能检查生成代码的类型一致性。这是宏 DSL 容易被忽略的收益——生成代码同样受静态分析覆盖。
调试与陷阱
宏的调试手段有限但够用。第一件工具是 Macro.expand/2 与 Macro.to_string/1:
iex> require MyMacros
iex> ast = quote do: MyMacros.double(5)
iex> ast |> Macro.expand(__ENV__) |> Macro.to_string()
"5 * 2"
在宏体里插入 IO.inspect(ast, label: "generated") 是更直接的办法,但记得在提交前删掉。Macro.Env.location/1 能给出准确的文件与行号,用于自定义警告。
常见的坑:
错误信息指向展开后的代码。宏生成的代码报错时,堆栈会指向 quote 块的位置,而非调用点。缓解方法是在 quote 上加 location: :keep,让行号透传:
quote location: :keep do
# ...
end
过度使用导致可读性崩塌。宏的调用点看不到实现,IDE 跳转失效,新成员需要理解两套语言(表面语法 + 展开结果)。经验法则是:能用函数就用函数,能用数据就用数据。只有当「调用点语法」本身构成 API 时(路由、Schema、校验规则),宏才是对的工具。
编译期执行任意代码。宏体在编译期运行,如果里面发起网络请求或读文件,构建就变得不可复现。编译期只应做纯计算与 AST 变换。
宏与函数的调用歧义。同名宏与函数并存时,import 会报冲突。用 only:/except: 精确导入,或把宏放进独立模块。
AST 结构假设过强。手写模式匹配 AST 三元组极易被 Elixir 版本变化打破。优先用 Macro 模块提供的 API(Macro.prewalk/2、Macro.decompose_call/1、Macro.extract_args/1),而不是自己匹配 {name, meta, args}。
宏与编译流水线
从更大的视角看,宏只是 Elixir 编译流水线里的一环。整个流程是:词法分析 → 解析成 AST → 展开宏 → 展开别名与 import → 编译成 Erlang 抽象格式 → 生成 BEAM 字节码。宏展开在别名解析之前,这意味着宏无法知道自己最终会被编译进哪个模块——除了通过 __CALLER__ 拿到调用环境。
defmacro whoami do
IO.puts("expanded in #{inspect(__CALLER__.module)}")
quote do: :ok
end
__CALLER__ 是 Macro.Env 结构体,包含 module、file、line、context_modules 等字段,是宏感知调用现场的唯一途径。多数「智能」DSL(例如自动推断表名、自动注入模块前缀)都靠它实现。
如果想看 Elixir 编译流水线的全貌,可以打印编译产物:
elixirc --ignore-module-conflict -o /tmp/beam lib/my_module.ex
elixir -e 'IO.inspect(:beam_lib.chunks(~c"/tmp/beam/Elixir.MyModule.beam", [:abstract_code]))'
读 BEAM 的抽象格式能看清宏展开后的真实结构,也是排查「宏生成了什么」的终极手段。理解这一层后,元编程与编译器前端的关系会清晰起来——Elixir 的宏展开本质上是一种可编程的语法重写,与 编译器 AST 求值器 中「把语法树变换成可执行结构」的思路同源。
实践建议
- 先写函数,再考虑宏。只有调用点语法本身是 API 时才值得宏化。
- 宏体只做 AST 变换。编译期不读文件、不发请求,保证构建可复现。
- 默认保持卫生,
var!/2只用于约定明确的 DSL。 - 用
@before_compile收集声明。这是声明式 DSL 的标准骨架:__using__注入属性 +@before_compile生成函数。 - 加
location: :keep。让报错指向调用点,而不是quote块。 - 为生成代码补
@spec。让 Dialyzer 覆盖宏产物,别让元编程成为静态分析的黑洞。 - 控制在项目内可维护的规模。宏是给框架作者的工具,业务代码里每多一个宏,可读性就少一分。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。