文章目录

Elixir 宏:defmacro 与 quote

发布于 2026-04-02 23:02:45 · 浏览 9 次 · 评论 0 条

Elixir 宏:defmacro 与 quote

Elixir 的宏系统让你能在编译期修改代码结构,实现“写代码生成代码”的能力。核心工具是 defmacroquote。掌握它们,你就能构建出简洁、强大的 DSL(领域特定语言)。


理解 quote:把代码变成数据

在 Elixir 中,代码本身是一种叫“抽象语法树”(AST)的数据结构。quote 的作用就是暂停执行一段代码,并返回它的 AST 表示。

执行以下代码:

ast = quote do
  1 + 2
end

此时 ast 不是 3,而是一个三元组:{:+, [context: Elixir, import: Kernel], [1, 2]}。它描述了“对 1 和 2 执行加法操作”。

你可以用 Macro.to_string/1 查看这段 AST 对应的源码形式:

IO.puts(Macro.to_string(ast))  # 输出: 1 + 2

关键点quote 不运行代码,只捕获其结构。


使用 defmacro:定义你的宏

defmacro 用于定义一个宏。宏的参数是 AST,返回值也必须是 AST。编译器会在调用处替换为宏返回的代码。

定义一个简单的宏:

defmodule MyMacros do
  defmacro unless(expr, do: block) do
    quote do
      if !unquote(expr), do: unquote(block)
    end
  end
end

这个宏模仿了 Elixir 内置的 unless。注意两点:

  • 宏接收的是 exprblock 的 AST。
  • quote 内部,用 unquote(expr) 插入传入的表达式。

使用这个宏:

require MyMacros

MyMacros.unless 2 == 3 do
  IO.puts("2 不等于 3")
end

编译时,这段代码会被替换为:

if !(2 == 3) do
  IO.puts("2 不等于 3")
end

unquote 的作用:在 quote 中插入变量

unquote 是连接宏外部变量和内部 quote 块的桥梁。没有它,quote 内部看到的只是符号名,而不是实际值。

对比两种写法:

value = 42

# 错误:quote 内部的 value 是原子 :value,不是数字 42
bad_ast = quote do
  value * 2
end

# 正确:用 unquote 插入变量的实际值
good_ast = quote do
  unquote(value) * 2
end

Macro.to_string(good_ast) 输出 "42 * 2",而 bad_ast 输出 "value * 2"


避免变量捕获:使用 bind_quoted

宏内部定义的变量可能意外覆盖调用者上下文中的同名变量,这叫“变量捕获”。Elixir 提供 bind_quoted 选项自动处理。

定义一个安全的宏:

defmacro log_with_prefix(prefix, msg) do
  quote bind_quoted: [prefix: prefix, msg: msg] do
    prefix_str = "LOG: #{prefix}"
    IO.puts("#{prefix_str} - #{msg}")
  end
end

bind_quoted 做了两件事:

  1. 自动对 prefixmsg 调用 unquote
  2. 将宏内部的变量(如 prefix_str)重命名为唯一名称,避免冲突。

调用时即使有同名变量也不会出错:

prefix = "USER"
log_with_prefix("SYSTEM", "启动成功")
# 正确输出: LOG: SYSTEM - 启动成功
# 不会使用外部的 prefix = "USER"

调试宏:展开看看实际生成的代码

宏的问题往往在编译期暴露。用 Macro.expand/2Code.string_to_quoted!/1 结合 Macro.to_string/1 查看宏展开结果。

创建一个测试模块:

defmodule TestMacro do
  require MyMacros

  def test do
    MyMacros.unless true do
      IO.puts("这不会打印")
    end
  end
end

在 iex 中检查展开后的函数体:

{:ok, quoted} = Code.string_to_quoted!("TestMacro.test()")
expanded = Macro.expand(quoted, __ENV__)
IO.puts(Macro.to_string(expanded))

输出会显示 if !true do ... end,验证宏是否按预期工作。


实战:构建一个简单的日志宏

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

实现步骤:

  1. 定义宏接收任意表达式。
  2. 用 quote 捕获表达式 AST。
  3. 用 unquote 插入表达式和元数据。
  4. 返回带调试信息的代码块。
defmodule DebugLog do
  defmacro debug_log(expr) do
    quote do
      result = unquote(expr)
      file = unquote(__CALLER__.file)
      line = unquote(__CALLER__.line)
      IO.puts("[DEBUG] #{file}:#{line} => #{unquote(Macro.to_string(expr))} = \#{inspect(result)}")
      result
    end
  end
end

使用这个宏:

require DebugLog

x = 10
y = DebugLog.debug_log(x * 2 + 1)

输出类似:

[DEBUG] lib/example.ex:5 => x * 2 + 1 = 21

注意:__CALLER__ 是特殊变量,包含宏调用点的编译环境信息。


宏的边界:何时不该用宏

宏强大但复杂。优先使用普通函数,仅在以下情况考虑宏:

  • 需要控制代码执行时机(如延迟求值)。
  • 需要访问代码结构本身(如自动注册路由)。
  • 构建 DSL 提升可读性(如 Ecto 查询语法)。

错误使用宏会导致:

  • 编译时间变长。
  • 错误信息难以理解。
  • 代码行为不符合直觉。

处理宏中的列表和关键字列表

宏常接收关键字列表(如 do: ...)。用模式匹配安全提取。

定义支持选项的宏:

defmacro timed(name, opts \\ [], do: block) do
  # opts 是关键字列表,如 [unit: :milliseconds]
  quote do
    start = System.monotonic_time()
    result = unquote(block)
    elapsed = System.monotonic_time() - start
    unit = unquote(opts[:unit] || :microseconds)
    converted = System.convert_time_unit(elapsed, :native, unit)
    IO.puts("⏱️  #{unquote(name)}: \#{converted} \#{unit}")
    result
  end
end

调用时传递选项:

timed "计算", unit: :milliseconds do
  :timer.sleep(100)
  42
end

宏的 hygiene(卫生性)规则

Elixir 宏默认是“卫生的”:宏内部创建的变量不会污染调用者作用域,调用者的变量也不会意外进入宏内部。

但有时你需要故意打破卫生性,比如让宏定义一个变量供外部使用。

定义一个非卫生宏:

defmacro assign_var(var_name, value) do
  quote do
    var!(unquote(var_name)) = unquote(value)
  end
end

var! 告诉编译器:这个变量属于调用者上下文。

使用

assign_var(:my_result, 99)
IO.puts(my_result)  # 输出 99

谨慎使用 var!,它增加耦合度。


测试宏:像测试普通代码一样

宏最终生成普通 Elixir 代码,所以可以直接写单元测试。

测试前面的 unless 宏:

defmodule MyMacrosTest do
  use ExUnit.Case
  import MyMacros

  test "unless executes block when condition is false" do
    assert capture_io(fn ->
      unless true do
        IO.puts("should not print")
      end
    end) == ""
  end

  test "unless skips block when condition is true" do
    output = capture_io(fn ->
      unless false do
        IO.puts("should print")
      end
    end)
    assert output == "should print\n"
  end
end

ExUnit.CaptureIO.capture_io/1 捕获 IO.puts 输出,验证行为正确性。


常见陷阱与解决方案

问题 原因 解决方案
宏返回非 AST 返回了普通值而非代码结构 确保 defmacro 最后返回 quote
变量名冲突 宏内部变量与调用者重名 使用 bind_quotedvar! 显式控制
调试困难 错误发生在编译期 Macro.to_string/1 打印展开代码
性能下降 宏逻辑过于复杂 将运行时逻辑移出宏,只保留编译期变换

终极检查清单

编写宏时逐项核对:

  1. 是否真的需要宏?先尝试用函数解决。
  2. 所有输入都用 unquote 插入?避免符号字面量。
  3. 变量是否隔离?用 bind_quoted 防止捕获。
  4. 返回值是合法 AST?用 Macro.validate/1 验证(开发期)。
  5. 有充分测试覆盖?包括边界条件和错误输入。
# 示例:验证 AST 合法性(仅开发用)
defmacro safe_macro(expr) do
  ast = quote do: unquote(expr)
  if Macro.validate(ast) do
    ast
  else
    raise "Invalid AST generated"
  end
end

评论 (0)

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

扫一扫,手机查看

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