Elixir宏:上下文、卫生性及应用实践
1. 上下文与宏卫生性
宏在注入代码时,需要特别关注调用者上下文和宏自身上下文。宏注入的代码不能默认某些变量可用。例如,以下宏定义尝试访问调用者上下文的变量:
defmodule ContextInfo do
defmacro grab_caller_context do
quote do
IO.puts x
end
end
end
在
iex
中加载该模块并调用宏,会得到编译错误:
iex(1)> c "context.exs"
[ContextInfo]
iex(2)> import ContextInfo
nil
iex(3)> x = 42
42
iex(4)> grab_caller_context
** (CompileError) iex:4: undefined function x/0
expanding macro: ContextInfo.grab_caller_context/0
iex:4: (file)
同样,宏不能安全地注入改变调用者上下文或环境的代码。例如,向
ContextInfo
模块添加一个尝试改变
x
值的宏:
defmacro inject_context_change do
quote do
x = 0
IO.puts x
end
end
调用该宏后,调用者上下文的
x
值并未改变:
iex(1)> c "context.exs"
[ContectInfo]
iex(2)> import ContextInfo
nil
iex(3)> x = 42
42
iex(4)> inject_context_change
0
:ok
iex(5)> x
42
下面两个模块更明确地展示了宏上下文与调用者上下文的区别:
defmodule MacroContext do
defmacro info do
IO.puts "Macro context: (#{__MODULE__})"
quote do
IO.puts "Caller context: (#{__MODULE__})"
def some_info do
IO.puts """
I am #{__MODULE__} and I come with the following:
#{inspect __info__(:functions)}
"""
end
end
end
end
defmodule MyModule do
require MacroContext
MacroContext.info
end
在
iex
中加载这两个模块,输出如下:
iex(1)> c "context2.exs"
Macro context: (Elixir.MacroContext)
Caller context: (Elixir.MyModule)
[MyModule, MacroContext]
iex(2)> MyModule.some_info
I am Elixir.MyModule and I come with the following:
[some_info: 0]
:ok
__MODULE__
宏能为每个上下文显示正确的模块名,这是不卫生宏的结果,下面将详细介绍。
2. 不卫生宏:覆盖上下文
编写好的宏时,有时需要覆盖上下文,类似于处理编程中的状态能产生有用的程序。Elixir提供了
var!
宏来实现这一点。
var!
宏非常通用,它允许我们在不将调用者上下文的成员传递给宏的情况下访问它们,并允许宏修改调用者的上下文。
修改之前的例子,添加
var!
:
defmodule ContextInfo do
defmacro grab_caller_context do
quote do
IO.puts var!(x)
end
end
end
在
iex
中加载并编译更新后的
ContextInfo
模块,宏可以访问调用者上下文的
x
变量:
iex(1)> c "context.exs"
[ContextInfo]
iex(2)> require ContextInfo
nil
iex(3)> x = 42
42
iex(4)> ContextInfo.grab_caller_context
42
:ok
同样,更新
inject_context_change
宏:
defmodule ContextInfo do
defmacro inject_context_change do
quote do
var!(x) = 0
end
end
end
在
iex
中加载更新后的模块,宏可以改变调用者上下文的
x
变量的值:
iex(1)> c "context.exs"
[ContextInfo]
iex(2)> require ContextInfo
nil
iex(3)> x = 42
42
iex(4)> ContextInfo.inject_context_change
0
iex(5)> x
0
使用
var!
宏允许宏从调用者上下文提取和注入值,实现不卫生操作。但需要注意,这是非函数式的,会增加宏的复杂性,使用时需谨慎权衡。
3. 宏的实际应用示例
3.1 调试和跟踪
我们可以创建一个调试/跟踪模块,自动跟踪库中方法的调用。首先,了解
use
和
__using__
。
use
是一个相对简单的函数,它调用传递模块的
__using__
宏,进而注入
__using__
宏的代码。
例如,定义一个基本模块
UsingTest
:
defmodule UsingTest do
defmacro __using__(_opts) do
quote do
IO.puts "I'm the __using__/1 of #{unquote(__MODULE__)}"
end
end
end
再定义一个简单模块
MyUsingTest
:
defmodule MyUsingTest do
use UsingTest
end
编译这两个模块,会输出:
iex(1)> c "usingtest.exs"
I'm the __using__ of Elixir.UsingTest
[TestMyUsing, UsingTest]
为了给库中的函数调用添加跟踪信息,我们需要重新定义
def
宏。创建一个
Tracer
模块:
defmodule Tracer do
defmacro def(definition={name, _, args}, do: content) do
quote do
Kernel.def(unquote(definition)) do
unquote(content)
end
end
end
defmacro __using__(_) do
quote do
import Kernel, except: [def: 2]
import unquote(__MODULE__), only: [def: 2]
end
end
end
目前,这个新的
def
宏只是增加了函数定义的间接层次,还没有实现跟踪功能。我们添加一个辅助函数
dump_args
来打印函数参数:
def dump_args(args) do
args |> Enum.map(&inspect/1) |> Enum.join(", ")
end
然后修改
def
宏,使用
dump_args
函数并打印函数名、参数和结果:
defmodule Tracer do
def dump_args(args) do
args |> Enum.map(&inspect/1) |> Enum.join(", ")
end
defmacro def(definition={name, _, args}, do: content) do
quote do
Kernel.def(unquote(definition)) do
IO.puts :stderr,
">>> Calling #{unquote(name)} with #{Tracer.dump_args(unquote(args))}"
result = unquote(content)
IO.puts :stderr, "<<< Result: #{Macro.to_string result}"
result
end
end
end
defmacro __using__(_) do
quote do
import Kernel, except: [def: 2]
import unquote(__MODULE__), only: [def: 2]
end
end
end
创建一个简单的排序模块
Quicksort
,并使用
Tracer
模块:
defmodule Quicksort do
use Tracer
def sort(list), do: _sort(list)
defp _sort([]), do: []
defp _sort(l = [h|_]) do
(l |> Enum.filter(&(&1 < h)) |> _sort)
++ [h] ++
(l |> Enum.filter(&(&1 > h)) |> _sort)
end
end
在
iex
中加载并调用
sort
函数,会输出跟踪信息:
iex(1)> import Quicksort
nil
iex(2)> 1..10 |> Enum.reverse |> Quicksort.sort
>>> Calling sort with [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
<<< Result: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
3.2 静态数据转换为函数
由于宏在编译时展开,我们可以在注入代码时做一些有趣的事情,例如从静态数据创建函数。Elixir使用文本文件中的Unicode代码点来创建与
String.upcase/1
、
String.downcase/1
等相关的函数,以支持完整的Unicode。
以下是
String.Unicode
模块中
String.downcase/1
函数的实现:
data_path = Path.join(__DIR__, "UnicodeData.txt")
{codes, whitespace} = Enum.reduce File.stream!(data_path), {[], []},
fn(line, {cacc, wacc}) ->
[codepoint, _name, _category,
_class, bidi, _decomposition,
_numeric_1, _numeric_2, _numeric_3,
_bidi_mirror, _unicode_1, _iso,
upper, lower, title] = :binary.split(line, ";", [:global])
title = :binary.part(title, 0, byte_size(title) - 1)
cond do
upper != "" or lower != "" or title != "" ->
{[{to_binary.(codepoint),
to_binary.(upper),
to_binary.(lower),
to_binary.(title)} | cacc],
wacc}
bidi in ["B", "S", "WS"] ->
{cacc, [to_binary.(codepoint) | wacc]}
true ->
{cacc, wacc}
end
end
def downcase(string), do: downcase(string, "")
for {codepoint, _upper, lower, _title} <- codes, lower && lower != codepoint do
defp downcase(unquote(codepoint) <> rest, acc) do
downcase(rest, acc <> unquote(lower))
end
end
defp downcase(<<char, rest :: binary>>, acc) do
downcase(rest, <<acc :: binary, char>>)
end
defp downcase("", acc), do: acc
这个例子展示了从文件加载静态数据并为模块生成函数的常见模式。
4. 测试宏
测试模块化代码通常比较容易,但测试宏可能有点像测试黑魔法。一般原则是测试生成的代码,而不是生成代码的能力。
例如,对于一个
while
宏,我们可以编写如下测试:
defmodule WhileTest do
use ExUnit.Case
test "while loops while truthy" do
pid = spawn(fn -> :thread.sleep(:infinity) end)
send self, :one
while Process.isAlive?(pid) do
receive do
:one -> send self, :two
:two -> send self, :three
:three ->
Process.exit(pid)
send self, :done
end
end
assert_received :done
end
end
对于标准库中的
String.upcase/1
函数,测试如下:
test "upcase" do
assert String.upcase("123 abcd 456 efg hij ( %$#) kl mnop @ qrst = -_ uvwxyz") == "123 ABCD 456 EFG HIJ ( %$#) KL MNOP @ QRST = -_ UVWXYZ"
assert String.upcase("") == ""
assert String.upcase("abcD") == "ABCD"
end
test "upcase utf8" do
assert String.upcase("& % # àáâ ãäå 1 2 ç æ") == "& % # ÀÁÂ ÃÄÅ 1 2 Ç Æ"
assert String.upcase("àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþ") == "ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ"
end
test "upcase utf8 multibyte" do
assert String.upcase("straße") == "STRASSE"
assert String.upcase("áüÈß") == "ÁÜÈSS"
end
测试宏应像测试模块的公共接口一样,测试注入代码生成的函数,将测试代码生成的问题转化为常规模块测试问题。
5. 特定领域语言(DSL)
宏可以创建小型嵌入式语言来解决特定问题。例如,Ecto项目使用Elixir和宏提供了类似SQL的语言来查询数据存储。
原本的SQL查询:
"select * from weather where prcp > 0" |> DB.Connector.query!
可以用Elixir的DSL表达为:
query = from w in Weather,
where: w.prcp > 0
select: w
这种方式可以在编译时检查查询,而将SQL查询写成字符串只能在执行时检查。
6. 创建DSL:XML模板
我们可以创建一个简单的DSL来模板化XML模型。
6.1 宏的状态管理
创建用于构建字符串的DSL时,需要一种保存状态的方法。Elixir提供的
Agents
可以轻量级地实现这一点。
以下是使用
Agent
创建键值存储的示例:
iex(1)> {:ok, kv} = Agent.start_link &HashDict.new/0
{:ok, #PID<0.60.0>}
iex(2)> Agent.get(kv, &(&1))
#HashDict<[]>
iex(3)> Agent.update(kv, &(HashDict.put(&1, :a, 2)))
:ok
iex(4)> Agent.get(kv, &(&1))
#HashDict<[a: 2]>
iex(5)> Agent.get(kv, &(&1)) |> HashDict.get :a
2
iex(6)> Agent.get(kv, &(HashDict.get(&1, :a)))
2
将这个示例改写为一个完整的
KV
模块:
defmodule KV do
def start_link do
Agent.start_link(&HashDict.new/0, name: __MODULE__)
end
def get(key) do
Agent.get(__MODULE__, &(HashDict.get(&1, key)))
end
def put(key, value) do
Agent.update(__MODULE__, &(HashDict.put(&1, key, value)))
end
end
在
iex
中使用该模块:
iex(1)> c "kv_store.exs"
[KV]
iex(2)> KV.start_link
{:ok, #PID<0.60.0>}
iex(3)> KV.put(:a, 2)
:ok
iex(4)> KV.get(:a)
2
6.2 定义XML DSL
XML由以下部分组成:
- 文档的任意元素,有些可能有属性
- 元素可以嵌套
- 元素必须正确关闭和嵌套
我们定义的DSL可能如下:
xml do
tag :model, name: "my model" do
for i <- 0..5 do
tag :attribute do
text "some value #{i}"
end
end
end
end
首先,我们定义一些辅助函数来存储、更新和检索用于构建输出的缓冲区:
def start_buffer(state), do: Agent.start_link(fn() -> state end)
def stop_buffer(buffer), do: Agent.stop(buffer)
def put_buffer(buffer, content), do: Agent.update(buffer, &[content | &1])
def render(buffer) do
Agent.get(buffer, &(&1)) |>
Enum.reverse |>
Enum.join("")
end
然后定义
xml
、
tag
和
text
宏:
defmacro xml(do: block) do
quote do
{:ok, var!(buffer, Xml)} = start_buffer(["<?xml version=\"1.0\"?>"])
unquote(block)
result = render(var!(buffer, Xml))
:ok = stop_buffer(var!(buffer, Xml))
result
end
end
defmacro tag(name, do: inner) do
quote do
put_buffer var!(buffer, Xml), "<#{unquote(name)}>"
unquote(inner)
put_buffer var!(buffer, Xml), "</#{unquote(name)}>"
end
end
defmacro text(string) do
quote do: put_buffer(var!(buffer, Xml), to_string(unquote(string)))
end
完整的
Xml
模块如下:
defmodule Xml do
def start_buffer(state), do: Agent.start_link(fn -> state end)
def stop_buffer(buffer), do: Agent.stop(buffer)
def put_buffer(buffer, content), do: Agent.update(buffer, &[content | &1])
def render(buffer) do
Agent.get(buffer, &(&1))
|> Enum.reverse
|> Enum.join("")
end
defmacro xml(do: block) do
quote do
{:ok, var!(buffer, Xml)} = start_buffer([])
put_buffer var!(buffer, Xml), "<?xml version=\"1.0\"?>"
unquote(block)
result = render(var!(buffer, Xml))
:ok = stop_buffer(var!(buffer, Xml))
result
end
end
defmacro tag(name, do: inner) do
quote do
put_buffer var!(buffer, Xml), "<#{unquote(name)}>"
unquote(inner)
put_buffer var!(buffer, Xml), "</#{unquote(name)}>"
end
end
defmacro text(string) do
quote do: put_buffer(var!(buffer, Xml), to_string(unquote(string)))
end
end
创建一个
Template
模块来使用
Xml
模块:
defmodule Template do
import Xml
def render do
xml do
tag :model do
for i <- 0..5 do
tag :attribute do
text "some value #{i}"
end
end
end
end
end
end
在
iex
中测试:
iex(1)> c "xml.exs"
[Xml]
iex(2)> c "template.exs"
[Template]
iex(3)> Template.render
"<?xml version=\"1.0\"?><model><attribute>some value 0</attribute><attribute>some value 1</attribute><attribute>some value 2</attribute><attribute>some value 3</attribute><attribute>some value 4</attribute><attribute>some value 5</attribute></model>"
最后,我们添加元素属性的支持。首先定义一个
open_tag
函数:
def open_tag(name, []), do: "<#{name}>"
def open_tag(name, attrs) do
attr_text = for {k, v} <- attrs, into: "", do: " #{k}=\"#{v}\""
"<#{name}#{attr_text}>"
end
修改
tag
宏以接受属性:
defmacro tag(name, attrs \\ [], do: inner) do
quote do
put_buffer var!(buffer, Xml), open_tag(unquote_splicing([name, attrs]))
unquote(inner)
put_buffer var!(buffer, Xml), "</#{unquote(name)}>"
end
end
更新
Template
模块以使用属性:
xml do
tag :model, name: "my_model" do
for i <- 0..5 do
tag :attribute do
text "some value #{i}"
end
end
end
end
再次在
iex
中测试,现在可以正确输出带有属性的XML:
iex(1)> c "xml.exs"
[Xml]
iex(2)> c "template.exs"
[Template]
iex(3)> Template.render
"<?xml version=\"1.0\"?><model name=\"my_model\"><attribute>some value 0</attribute><attribute>some value 1</attribute><attribute>some value 2</attribute><attribute>some value 3</attribute><attribute>some value 4</attribute><attribute>some value 5</attribute></model>"
通过以上示例,我们可以看到Elixir宏在不同场景下的强大功能,包括上下文管理、代码生成、测试和DSL创建等。合理使用宏可以提高代码的灵活性和可维护性。
Elixir宏:上下文、卫生性及应用实践(续)
7. 宏应用总结与对比
为了更清晰地理解宏在不同场景下的应用,我们对前面提到的几种应用进行总结和对比,如下表所示:
| 应用场景 | 核心功能 | 关键代码元素 | 优势 |
| — | — | — | — |
| 调试和跟踪 | 自动跟踪库中方法的调用 |
Tracer
模块、
def
宏重定义、
dump_args
函数 | 方便调试,可在编译时注入跟踪信息 |
| 静态数据转换为函数 | 从静态数据创建函数 |
String.Unicode
模块、列表推导、
Enum.reduce
| 支持动态更新,无需修改代码 |
| 测试宏 | 测试生成的代码 |
ExUnit.Case
、测试用例编写 | 将测试问题转化为常规模块测试 |
| 特定领域语言(DSL) | 创建小型嵌入式语言 |
Ecto
项目、
from...where...select
语法 | 以自然的Elixir术语表达特定问题,编译时检查查询 |
| XML模板DSL | 模板化XML模型 |
Xml
模块、
xml
/
tag
/
text
宏、
Agent
状态管理 | 简洁地将数据导出为XML,无需额外装饰 |
8. 宏使用的注意事项
虽然宏在Elixir中提供了强大的功能,但使用时也需要注意以下几点:
-
卫生性问题
:如前文所述,宏的上下文和调用者上下文需要仔细处理。不卫生的宏(如使用
var!
)虽然能实现一些特殊功能,但会增加代码的复杂性和不可预测性。在使用不卫生宏时,需要充分考虑其对代码的影响。
-
性能影响
:宏在编译时展开,可能会增加编译时间。特别是在处理大量静态数据生成函数或复杂的DSL时,需要注意性能问题。
-
代码可读性
:过度使用宏可能会使代码变得难以理解和维护。在编写宏时,应确保代码的可读性,必要时添加详细的注释。
9. 未来可能的扩展
基于前面的示例和讨论,我们可以设想一些未来可能的扩展方向:
-
更复杂的DSL
:可以进一步扩展XML模板DSL,支持更多的XML特性,如命名空间、CDATA等。也可以创建其他领域的DSL,如用于数据分析、机器学习等。
-
宏的组合使用
:将不同类型的宏组合使用,实现更复杂的功能。例如,将调试和跟踪宏与DSL结合,在DSL中添加调试信息。
-
与其他库的集成
:将宏与其他Elixir库集成,如与
Phoenix
框架集成,为Web应用提供更强大的功能。
10. 总结
Elixir宏是一种强大的工具,它允许开发者在编译时注入代码,实现一些在运行时难以实现的功能。通过本文的介绍,我们了解了宏的上下文和卫生性问题,以及如何使用宏进行调试、静态数据处理、测试、创建DSL等。在使用宏时,需要权衡其带来的好处和可能的风险,确保代码的可读性和可维护性。
以下是一个简单的mermaid流程图,展示了创建XML模板DSL的主要流程:
graph TD;
A[开始] --> B[初始化缓冲区];
B --> C[处理XML块];
C --> D{是否有标签};
D -- 是 --> E[插入标签开始];
E --> F{是否有内部内容};
F -- 是 --> G[处理内部内容];
F -- 否 --> H[插入标签结束];
G --> H;
H --> D;
D -- 否 --> I[渲染结果];
I --> J[停止缓冲区];
J --> K[返回结果];
K --> L[结束];
通过这个流程图,我们可以更直观地看到创建XML模板DSL的主要步骤,包括缓冲区的管理、标签的处理和结果的渲染。希望这些内容能帮助你更好地理解和应用Elixir宏。
超级会员免费看
2

被折叠的 条评论
为什么被折叠?



