Elixir 宏:defmacro 与 quote
Elixir 的宏系统让你能在编译期修改代码结构,实现“写代码生成代码”的能力。核心工具是 defmacro 和 quote。掌握它们,你就能构建出简洁、强大的 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。注意两点:
- 宏接收的是
expr和block的 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 做了两件事:
- 自动对
prefix和msg调用unquote。 - 将宏内部的变量(如
prefix_str)重命名为唯一名称,避免冲突。
调用时即使有同名变量也不会出错:
prefix = "USER"
log_with_prefix("SYSTEM", "启动成功")
# 正确输出: LOG: SYSTEM - 启动成功
# 不会使用外部的 prefix = "USER"
调试宏:展开看看实际生成的代码
宏的问题往往在编译期暴露。用 Macro.expand/2 或 Code.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 宏,自动包含调用位置信息。
实现步骤:
- 定义宏接收任意表达式。
- 用 quote 捕获表达式 AST。
- 用 unquote 插入表达式和元数据。
- 返回带调试信息的代码块。
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_quoted 或 var! 显式控制 |
| 调试困难 | 错误发生在编译期 | 用 Macro.to_string/1 打印展开代码 |
| 性能下降 | 宏逻辑过于复杂 | 将运行时逻辑移出宏,只保留编译期变换 |
终极检查清单
编写宏时逐项核对:
- 是否真的需要宏?先尝试用函数解决。
- 所有输入都用 unquote 插入?避免符号字面量。
- 变量是否隔离?用
bind_quoted防止捕获。 - 返回值是合法 AST?用
Macro.validate/1验证(开发期)。 - 有充分测试覆盖?包括边界条件和错误输入。
# 示例:验证 AST 合法性(仅开发用)
defmacro safe_macro(expr) do
ast = quote do: unquote(expr)
if Macro.validate(ast) do
ast
else
raise "Invalid AST generated"
end
end
暂无评论,快来抢沙发吧!