文章目录

Elixir 宏:defmacro 与 quote

发布于 2026-04-02 23:30:24 · 浏览 12 次 · 评论 0 条

Elixir 宏:defmacro 与 quote

Elixir 的宏系统让你能在编译期修改代码结构,实现高级抽象。defmacroquote 是构建宏的两个核心工具。掌握它们,你就能写出像 Elixir 标准库那样简洁而强大的 DSL(领域特定语言)。


理解 quote:把代码变成数据

在 Elixir 中,代码本身就是一种数据结构(称为“抽象语法树”或 AST)。quote 的作用就是暂停代码的执行,把它转换成可操作的数据。

执行以下代码:

code = quote do
  1 + 2
end

变量 code 不会等于 3,而是等于一个表示“1 加 2”这个表达式的元组:

{:+, [context: Elixir, import: Kernel], [1, 2]}

这个元组包含三部分:

  • 操作符 :+
  • 元信息(如上下文和导入模块)
  • 参数列表 [1, 2]

记住quote 不运行代码,它只是“拍照”——把代码结构原样保存下来。


使用 defmacro:定义你的宏

defmacro 用于定义宏。宏在编译时运行,接收参数(通常是 AST),返回新的 AST,然后被插入到调用处。

创建一个简单的宏 unless,它是 if 的反向逻辑:

defmodule MyMacros do
  defmacro unless(condition, do: block) do
    quote do
      if not unquote(condition), do: unquote(block)
    end
  end
end

注意三个关键点:

  1. 宏参数 conditionblock 是 AST,不是值。
  2. quote 包裹返回的代码模板。
  3. unquote 用于在 quote 内部“展开”传入的 AST。

使用这个宏:

require MyMacros

MyMacros.unless true do
  IO.puts "这不会打印"
end

编译器会把这行替换成:

if not true do
  IO.puts "这不会打印"
end

unquote 的作用:在 quote 中插入动态内容

unquote 是连接宏参数和生成代码的桥梁。没有它,quote 内部的内容都是字面量。

对比以下两种写法:

# 错误:condition 被当作变量名,而不是传入的表达式
quote do
  if not condition, do: block
end

# 正确:unquote 把传入的 AST 插入到生成的代码中
quote do
  if not unquote(condition), do: unquote(block)
end

验证差异:假设调用 unless File.exists?("missing.txt"), do: :ok

  • 错误版本生成的代码是 if not condition, do: block,而 conditionblock 在运行时不存在。
  • 正确版本生成的是 if not File.exists?("missing.txt"), do: :ok,完全符合预期。

宏的 hygiene(卫生性)规则

Elixir 宏默认是“卫生的”:宏内部定义的变量不会污染调用者的上下文。

观察这个宏:

defmacro debug_print(value) do
  quote do
    temp = unquote(value)
    IO.inspect(temp)
    temp
  end
end

即使你在函数里也叫 temp,也不会冲突:

temp = "外部变量"
result = debug_print(42)
# 输出 42,result 是 42,外部 temp 仍是 "外部变量"

如果你确实需要让宏引入的变量影响外部作用域,使用 var!(name)

defmacro introduce_x do
  quote do
    x = 100
    var!(x) = x  # 显式声明 x 应该属于调用者的作用域
  end
end

调试宏:查看生成的代码

宏的问题往往难以调试,因为错误发生在编译期。Elixir 提供了 Macro.expand_once/2Macro.to_string/1 来帮助你。

步骤如下:

  1. 启动 iex -S mix
  2. 定义你的宏模块
  3. 构造一个调用宏的 quoted 表达式
  4. 展开一次并转为字符串查看

例如:

ast = quote do
  MyMacros.unless false, do: :hello
end

expanded = Macro.expand_once(ast, __ENV__)
IO.puts Macro.to_string(expanded)

输出将是:

if not(false) do
  :hello
end

这让你清晰看到宏到底生成了什么代码。


常见陷阱与最佳实践

避免以下错误,能让你少走弯路:

  1. 不要在宏里做运行时计算
    宏在编译期运行,无法访问运行时数据(如数据库、文件内容)。如果需要运行时行为,写普通函数。

  2. 总是用括号包裹多行宏体
    如果宏返回多个表达式,用 quote do ... end 包裹,否则只返回最后一行。

  3. 谨慎使用 unquote_splicing
    unquote_splicing 用于展开列表为多个参数,但容易出错。除非你明确知道 AST 结构,否则优先用 unquote

  4. 宏应该尽量简单
    复杂逻辑拆分为私有函数,在宏内部调用这些函数处理 AST。

  5. 文档化宏的行为
    @doc 清晰说明宏的用途、参数和生成的代码结构。


实战:构建一个日志宏

目标:写一个 log/1 宏,自动包含调用位置信息。

defmodule LoggerMacros do
  defmacro log(msg) do
    quote do
      IO.puts "[#{__MODULE__}:#{__ENV__.line}] #{unquote(msg)}"
    end
  end
end

使用它:

defmodule MyApp do
  require LoggerMacros

  def run do
    LoggerMacros.log("程序开始")
  end
end

输出类似:

[MyApp:5] 程序开始

这里 __MODULE____ENV__.line 在宏展开时被捕获,所以记录的是 log 宏被调用的位置,而不是宏定义的位置。


宏与函数的选择原则

不是所有抽象都要用宏。遵循这个决策流程:

  1. 先尝试写普通函数
    函数更简单、更容易测试、堆栈跟踪清晰。

  2. 只有当需要以下能力时才用宏

    • 修改控制流(如 unless, try
    • 自动生成重复代码(如 defstruct
    • 构建 DSL(如 Ecto.Query.from
    • 访问调用位置信息(如 __FILE__, __LINE__
  3. 宏的接口应尽量接近函数
    用户不应该因为用了宏而改变调用方式。


宏的编译顺序注意事项

宏必须在使用前被编译。这意味着:

  • 不能在同一个模块中先使用宏再定义它。
  • 必须require ModuleName 引入定义宏的模块。
  • 不能循环依赖:模块 A 的宏依赖模块 B,而模块 B 又依赖模块 A 的宏。

正确做法:把宏单独放在一个模块中,其他模块通过 require 使用。

# macros.ex
defmodule MyMacros do
  defmacro my_macro(...), do: ...
end

# user.ex
defmodule MyModule do
  require MyMacros
  MyMacros.my_macro(...)
end

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文