Elixir 元编程与宏:AST、quote/unquote 与 DSL 设计

深入 Elixir 元编程:AST 三元组表示与 quote/unquote 语义、defmacro 的编译期展开、卫生宏与 var!/2 的边界、@before_compile 与 __using__ 注入、用宏构建声明式 DSL 的实战,以及调试手段与过度使用的代价。

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__/1use 语句处立即展开注入 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 求值器 中「把语法树变换成可执行结构」的思路同源。

实践建议

  1. 先写函数,再考虑宏。只有调用点语法本身是 API 时才值得宏化。
  2. 宏体只做 AST 变换。编译期不读文件、不发请求,保证构建可复现。
  3. 默认保持卫生,var!/2 只用于约定明确的 DSL。
  4. 用 @before_compile 收集声明。这是声明式 DSL 的标准骨架:__using__ 注入属性 + @before_compile 生成函数。
  5. 加 location: :keep。让报错指向调用点,而不是 quote 块。
  6. 为生成代码补 @spec。让 Dialyzer 覆盖宏产物,别让元编程成为静态分析的黑洞。
  7. 控制在项目内可维护的规模。宏是给框架作者的工具,业务代码里每多一个宏,可读性就少一分。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「erlang」更多文章

  1. BEAM 内存剖析与泄漏排查:recon、observer 与堆分析
  2. 分布式一致性与网络分区:CRDT、libcluster 与脑裂治理
  3. 缓存、限流与熔断:Cachex、Hammer 与降级策略