Elixir 宏:defmacro 与 quote
Elixir 的宏系统让你能在编译期修改代码结构,实现高级抽象。defmacro 和 quote 是构建宏的两个核心工具。掌握它们,你就能写出像 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
注意三个关键点:
- 宏参数
condition和block是 AST,不是值。 quote包裹返回的代码模板。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,而condition和block在运行时不存在。 - 正确版本生成的是
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/2 和 Macro.to_string/1 来帮助你。
步骤如下:
- 启动
iex -S mix - 定义你的宏模块
- 构造一个调用宏的 quoted 表达式
- 展开一次并转为字符串查看
例如:
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
这让你清晰看到宏到底生成了什么代码。
常见陷阱与最佳实践
避免以下错误,能让你少走弯路:
-
不要在宏里做运行时计算
宏在编译期运行,无法访问运行时数据(如数据库、文件内容)。如果需要运行时行为,写普通函数。 -
总是用括号包裹多行宏体
如果宏返回多个表达式,用quote do ... end包裹,否则只返回最后一行。 -
谨慎使用 unquote_splicing
unquote_splicing用于展开列表为多个参数,但容易出错。除非你明确知道 AST 结构,否则优先用unquote。 -
宏应该尽量简单
复杂逻辑拆分为私有函数,在宏内部调用这些函数处理 AST。 -
文档化宏的行为
用@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 宏被调用的位置,而不是宏定义的位置。
宏与函数的选择原则
不是所有抽象都要用宏。遵循这个决策流程:
-
先尝试写普通函数
函数更简单、更容易测试、堆栈跟踪清晰。 -
只有当需要以下能力时才用宏:
- 修改控制流(如
unless,try) - 自动生成重复代码(如
defstruct) - 构建 DSL(如
Ecto.Query.from) - 访问调用位置信息(如
__FILE__,__LINE__)
- 修改控制流(如
-
宏的接口应尽量接近函数
用户不应该因为用了宏而改变调用方式。
宏的编译顺序注意事项
宏必须在使用前被编译。这意味着:
- 不能在同一个模块中先使用宏再定义它。
- 必须用
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
暂无评论,快来抢沙发吧!