Elixir元编程-第一章 宏语言
注:本章内容来自《Metaprogramming Elixir》一书,写的非常好,强烈推荐。内容不是原文照翻,部分文字采取意译,主要内容都基本保留,加上自己的一些理解描述。为更好理解,建议参考原文。
是时候来探索元编程了。学习了Elixir的基础知识,或许你想写出更好的产品库,或者构建一门 dsl,或者优化运行性能。或许你只是想简单体会一下 Elixir 超强能力给你带来的乐趣。如果这就是你想要的,那么我们开始吧!
现在,我假定你已经熟悉了 Elixir;你已经体验过这门语言,或许还发布过一两个库。我们要进入新的阶段,开始学习通过宏来编写生成代码的代码。Elixir 宏是用来改变游戏规则的。由它开启的元编程会让我们编写强大程序时信手拈来。
生成代码的代码,听起来有点拗口,但你不久就会看到它是如何组织起 Elixir 语言本身的基础架构。宏开启了在其他语言中完全不可能的一扇大门。使用恰当的话,元编程可以编写清晰、简洁的程序,我们可以塑造代码,而非教条地运用指令。
我们会讲述 Elixir 所需的一切知识,然后放手去干吧。
让我们开始吧。
整个世界都是你的游乐场
Elixir 中的元编程全部都是关于扩展能力的。你是否曾希望你喜欢的语言具备某种小巧优雅的特性?如果你走运的话,几年后这种特性也许会添加到语言中。事实上这种事基本未发生过。在 Elixir 中,只要你愿意你可以任意引入新特性。比如在很多语言中你都很熟悉的 while 循环。在 Elixir 中是没有这个的,但你又想用它,比如:
while Process.alive?(pid) do
send pid, {self, :ping}
receive do
{^pid, :pong} -> IO.puts "Got pong"
after 2000 -> break
end
end
下一章,我们就来编写这个 while 循环。不止于此,使用 Elixir,我们可以用语言定义语言,比如使用自然语法表达某些问题。下面这是一段有效的 Elixir 程序哦:
div do
h1 class: "title" do
text "Hello"
end
p do
text "Metaprogramming Elixir"
end
end
"<div><h1 class=\"title\">Hello</h1><p>Metaprogramming Elixir</p></div>"
Elixir 使这种类似编写 HTML 的 dsl 为可能。事实上,我们只需要几章的学习就可以编写这个程序了。你现在还不需要理解这是怎么干的,我们会学到的。现在你只需要知道,宏使得这一切成为可能。编写代码的代码。Elixir将这个理念贯彻的如此之深,远超你的想象。
正如一个游乐场,你总是从一小块地方开始,然后以你的方式不断探索新的领域。元编程会是较难理解掌握的,对它的运用也需要考虑更高阶的问题。贯穿本书,我们会通过大量的简单练习,初步揭开神秘面纱,最终掌握高阶的代码生成技术。在开始编写代码前,我们先回顾下 Elixir 元编程中非常重要的两个基本原则,以及他们是如何协作的。
抽象语法树:AST
要掌握元编程,首先你需要理解 Elixir 是如何使用 AST 在内部表示 Elixir 代码的。你接触到的绝大多数的语言都会使用 AST,但基本上你也会无视它。当你的程序被编译或解释执行时,源代码会被转换成树结构,然后编译成字节码或机器码。这个过程一般都是不可见的,你也从来不会注意到它。
José Valim,Elixir语言的发明者,选择了不同的处理方式。他用 Elixir 自己的数据结构来保存 AST 格式,并将其暴露出来,然后提供自然的语法来同其交互。使用普通 Elixir 代码就能访问 AST,这让你获得了编译器或者语言设计者才拥有的访问底层能力,你就能做一些非常强大的事情。在元编程的每个阶段,你都在同 Elixir 的 AST 进行交互,那么就让我们深入探索下它到底是什么。
Elixir 中的元编程涉及分析和修改 AST。你可以使用 quote 宏来访问任意 Elixir 表达式的 AST 结构。代码生成极度依赖于 quote,贯穿本书的所有练习都离不开他。我们研究下用它获取的一些基本表达式的 AST 结构。
输入以下代码,观察返回结果:
iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}
iex> quote do: div(10, 2)
{:div, [context: Elixir, import: Kernel], [10, 2]}
我们可以看到 1 + 2 和 div 表达式的 AST 结构,就是用 Elixir 自身的简单的数据结构来表示。让我们沉思片刻,你可以访问用 Elixir 数据结构保存的你写的任意代码的的表达(译注:实际上就是代码即数据,数据即代码)。quote 表达式所能带给你的东西你见所未见:能够审视你所编写代码的内部展现,而且是用你完全知道和理解的数据结构。这让你在Elixir高阶语法层面更好的理解代码,优化性能,以及扩展功能。(译注:这里的高阶就是指普通Elixir语法,相比 AST 它确实是高阶;就好比 C 语言之于汇编)
拥有了 AST 的全部访问能力,我们就能够在编译阶段耍一些优雅的小把戏。比如,Elixir 标准库中的 Logger 模块,可以通过从 AST 中彻底删除对应表达式来优化日志功能(译注:即开发调试时运行日志,最终发布版本时自动删除所有日志,而且是从 AST 删除,对发布版来说,该日志从未存在过)。比如说,我们在写入一个文件时,希望在开发阶段打印文件路径,但在产品发布阶段则完全忽略这个动作。我们可能写出如下代码:
def write(path, contents) do
Logger.debug "Writing contents to file #{path}"
File.write!(path, contents)
end
在产品发布阶段,Logger.debug 表达式会彻底从程序中删除。这是因为我们在编译时可以完全操作 AST,从而跳过同开发阶段相关的代码。大多数语言不得不调用 debug 函数,检测运行时忽略的 log 等级,纯属浪费 CPU 时间,因为这些语言根本无法操纵 AST。
探究 Logger.debug 是如何做到这一点的,这就把我们引领到元编程的一个重要概念面前:宏(macros)。
宏
宏就是编写代码的代码。终其一生其作用就是用 Elixir 的高阶语法同 AST 交互。这也是为什么 Logger.debug 看起来像普通的 Elixir 代码,但却能完成高超的优化技巧。
宏无处不在,既可以用来构建 Elixir 标准库,也可以用来构建 web 框架的核心架构。不管哪种情况,使用的都是相同的元编程规则。你无须在复杂性,性能快慢,API 的简洁优雅上妥协。Elixr 宏能让你编写简单又高效的代码。它让你--程序员,从单纯的语言使用者,变成语言的创建者。只要你用这门语言,要不了多久,你就会使用到 José 用来构建这门语言的标准库的所有工具和威力。他开放了这门语言,允许你自己扩展。一旦你体验过这种威力,食髓知味,你就很难回头了。
你可能会想直到目前你都在尽量避免使用宏,但其实这些宏一直都在,静静的隐藏在幕后。看下下面这段简单代码:
defmodule Notifier do
def ping(pid) do
if Process.alive?(pid) do
Logger.debug "Sending ping!"
send pid, :ping
end
end
end
看上去平平无奇,但我们已经发现了四个宏。在语言内部,defmodule,def,if,甚至 Logger.debug 都是用宏实现的,Elixir 大多数的顶层结构也基本如此。你可以自己在 iex 里面查看下文档:
iex> h if
defmacro if(condition, clauses)
Provides an if macro. This macro expects the first argument to be a condition
and the rest are keyword arguments.
你可能会好奇 Elixir 在自己的架构中使用宏有什么优势,大多数其他语言没有这玩意儿不也挺好的吗。宏最强大的一个功能就是你可以自己定义语言的关键字,就基于现有的宏作为构建基石就行。
要理解 Elixir 中的元编程,就要抛弃那些封闭式语言以及死板僵化的保留字那套陈腐观念。Elixir 被设计成可以随意扩展。这门语言是开放的,可以任意探索,任意定制。这也是为何在 Elixir 实现元编程是如此的自然舒服。
知识汇总一下
我们已经见识过了 Elixir 自身是如何由宏构建的,以及使用 quote 如何返回任意表达式的 AST 格式。现在我们把知识汇总一下。最重要的一点要知道宏接受 AST 作为参数,然后返回值一定也是一个 AST。所谓编写宏,就是用 Elixir 的高阶语法构建 AST。
要了解这套机制如何运作,我们先编写一个宏用来输出一个 Elixir 数学表达式在计算结果时产生的可读格式,比如 5 + 2。在大多数语言当中,我们只能解析表达式的字符串,将其转化成程序能够识别的格式。在 Elixir 中,我们能够直接使用宏访问表达式的内部展现形式。
我们第一步是分析我们的宏要接受的表达式的 AST 结构。我们使用 iex 然后 quote 一些表达式。自己去尝试下,好好体会下 AST 的结构。
iex> quote do: 5 + 2
{:+, [context: Elixir, import: Kernel], [5, 2]}
iex)> quote do: 1 * 2 + 3
{:+, [context: Elixir, import: Kernel],
[{:*, [context: Elixir, import: Kernel], [1, 2]}, 3]}
5 + 2 跟 1 * 2 + 3 表达式的 AST 直接就是个元组。:+
跟 :*
两个 atom 代表操作符,左右参数放在最后一个元素当中。三元组结构就是 Elixir 的高阶表达形式。
现在我们知道表达式是如何表示的了,让我们定义第一个宏来看看 AST 是如何配合的。我们会定义一个 Math 模块,包含一个 say 宏,能够以自然语言形式在任意数学表达式求值时将其输出。
创建一个 math.exs 文件,添加如下代码:
macros/math.exs
defmodule Math do
# {:+, [context: Elixir, import: Kernel], [5, 2]}
defmacro say({:+, _, [lhs, rhs]}) do
quote do
lhs = unquote(lhs)
rhs = unquote(rhs)
result = lhs + rhs
IO.puts "#{lhs} plus #{rhs} is #{result}"
result
end
end
# {:*, [context: Elixir, import: Kernel], [8, 3]}
defmacro say({:*, _, [lhs, rhs]}) do
quote do
lhs = unquote(lhs)
rhs = unquote(rhs)
result = lhs * rhs
IO.puts "#{lhs} times #{rhs} is #{result}"
result
end
end
end
在 iex 里加载测试:
iex> c "math.exs"
[Math]
iex> require Math
nil
iex> Math.say 5 + 2
5 plus 2 is 7
7
iex> Math.say 18 * 4
18 times 4 is 72
72
分解下程序。我们知道宏接受 AST 格式的参数,因此我们直接使用模式匹配,来确定该调用哪一个 say。第4到15行,是宏定义,跟函数类似,可以有多个签名。知道了结果 quoted 后的格式,因此我们可以很容易地将左右两边的值绑定到变量上,然后输出对应信息。
要完成宏功能,我们还要通过 quote 返回一个 AST 给调用者,用来替换掉 Math.say 调用。这里我们第一次用到 unquote。我们后面会详述 quote 跟 unquote。现在,你只需要知道这两个宏协同工作来帮助你创建 AST,他们会帮助你跟踪代码的执行空间。
先把那些条条框框放一边,我们现在已经深入到了 Elixir 元编程体系的细节中。你已经见识到宏跟 AST 协同工作,现在让我们研究它是如何运作的。但首先,我们要讨论一些东西。
宏的规则
在开始编写更复杂的宏之前,我们需要强调一些规则,以便更准确调整预期。宏给我们带来神奇的力量,但能力越大,责任越大。
规则1:不要编写宏
当你同其他人谈论元编程时,可能已经早就被警告过了。尽管这是毫无道理的,但在我们陷入狂热前,我们还是要牢记编写生成代码的代码需要格外小心。如果鲁莽行事,我们很容易陷入困境。如果走得太远,宏会使得程序难以调试,难以分析。当然元编程肯定有某种显著的优点的。但一般来说,如果没必要生成代码,那我们就用标准函数定义好了。
规则2:随便用宏
有人说元编程有时是复杂而脆弱的。我们会通过利用一小段必要代码来生成健壮,清晰的程序来驳斥这种说法。不要被 Elixir 宏系统可能带来的一点点晦涩所吓倒,而放弃对宏系统的深入探索。学习元编程的最好方式就是开放思想,放弃成见,保持好奇心。学习时甚至可以有点小小的不负责任(意为大胆尝试)。
编写宏的时候可以秉持以上双重标准。在你的元编程之旅,你会看到如何可靠地运用你的熟练技巧,同时学会如何有效地避开常见陷阱。优秀的代码自己会说话,我们就是要充分挖掘它。
抽象语法树--揭开神秘面纱
是时候深入探索 AST 了,我们来学习源码展现的不同形式。你可能急于现在就一头跳进去,马上开始编写宏,但真正理解 AST 是后面学习元编程的重中之重。一旦你深入理解了它的精微奥妙,你会发现 Elixir 代码远比你想象得更接近 AST。后面的内容会颠覆你对解决问题的思考方式,并驱使你的宏能力不断进步。学习了优雅的 AST 后,我们将可以开始元编程练习了。有点耐心。你会在真正了解所有这些技术之前就创建了新的语言特性。
AST 的结构
你所编写的每一个 Elixir 表达式都会分解成一个三元组格式的 AST。你会经常使用这种统一格式来进行模式匹配,分解参数。在前面的 Math.say 的定义中,我们已经用到了这种技术。
defmacro say({:+, _, [lhs, rhs]}) do
既然我们已经知道了表达式 5 + 2 会转化成 {:+, [...], [5, 2]} 元组,我们就可以直接模式匹配 AST,获取计算的含义。让我们 quote 一些更复杂的表达式,来看看 Elixir 程序是如何完整地用 AST 表示。
iex> quote do: (5 * 2) - 1 + 7
{:+, [context: Elixir, import: Kernel],
[{:-, [context: Elixir, import: Kernel],
[{:*, [context: Elixir, import: Kernel], [5, 2]}, 1]}, 7]}
iex> quote do
...> defmodule MyModule do
...> def hello, do: "World"
...> end
...> end
{:defmodule, [context: Elixir, import: Kernel],
[{:__aliases__, [alias: false], [:MyModule]},
[do: {:def, [context: Elixir, import: Kernel],
[{:hello, [context: Elixir], Elixir}, [do: "World"]]}]]}
你可以看到每一个 quoted 的表达式形成了一个堆栈结构的元组。第一个例子同 Math.say 宏的基本结构是类似的,不过是有更多的元组嵌套在一起组成树状结构用来表达一个完整的表达式。第二个例子展示了一个完整的 Elixir 模块是如果用一个简单的 AST 结构来表示的。
其实一直以来,你所编写 Elixir 代码都是用这种简单一致的结构来展现的。理解这种结构,只需要了解几条简单规则就行了。所有的 Elixir 代码都表示为一系列的三元组,其格式如下:
- 第一个元素是一个 atom,表示函数调用,或者是另一个元组,表示 AST 中嵌套的节点。
- 第二个元素是表达式的元数据。
- 第三个元素是一个参数列表,用于函数调用。
我们用这个规则来分解下上面例子中 (5 * 2) - 1 + 7
这个表达式的 AST:
iex(1)> quote do: (5 * 2) - 1 + 7
{:+, [context: Elixir, import: Kernel],
[{:-, [context: Elixir, import: Kernel],
[{:*, [context: Elixir, import: Kernel], [5, 2]}, 1]}, 7]}
我们看到 AST 格式就是一棵函数和其参数构成的树。我们对输出结构美化下,把这棵树看得更清楚些:
让我们从 AST 的终点向下遍历,AST 的 root 节点是 + 操作符,参数是数字 7 和另一个嵌入节点。我们看到嵌入节点包含 (5*2)
表达式,它的计算结果又用于 - 1 这条分支。你应该还记得 5 * 2
在 Elixir 中不过是 Kernel.*(5,2)
调用的语法糖。这样我们的表达式更容易解码。原子 :*
,就是个函数调用,元数据告诉我们它是从 Kernel import 过来的。后面的元素 [5,2] 就是 Kernel.*/2
函数的参数列表。全部的程序都是这样通过一个简单 Elixir 元组构成的树来表示的。
高阶语法 vs. 低阶 AST
要理解 Elixir 语法跟 AST 背后的设计哲学,最好的办法莫过于拿来同其他语言比较一下,看看 AST 处于什么位置。在某些语言当中,比如很有个性的 Lisp,它直接用 AST 编写,用括号组织表达式。如果你看的仔细,会发现 Elixir 某种程度上也是这种格式。
Lisp: (+ (* 2 3) 1)
Elixir(这里去掉了元数据)
quote do: 2 * 3 + 1
{:+, _, [{:*, _, [2, 3]}, 1]}
如果你比较 Elixir AST 跟 Lisp 的源码,将括号都换成圆括号,就会发现他们的结构基本上都是一样的。Elixir 干的漂亮的地方在于从高阶的源码转换到低阶的 AST 只需要一个简单的 quote 调用。而对于 Lisp,你确实拥有了可编程的 AST 的全部威力,可代价是不够自然不够灵活的语法。José 革命性的创新就在于将语法同 AST 分离。在 Elixir 中,你可以同时拥有这两样最好的东西:可编程的 AST,以及可通过高阶语法进行访问。
AST 字面量
当你开始探索 Elixir 源码是如何用 AST 表达时,有时会发现 quoted 的表达式看上去令人困惑,似乎也不大规范。要破解这个困惑,你需要知道 Elixir 中的一些字面量在 AST 中和高阶源码中的表现形式是一样的。这包括 atom,整数,浮点数,list,字符串,还有任意的包含 former types 的二元组。例如,下面这些字面量在 quoted 时直接返回自身:
iex> quote do: :atom
:atom
iex> quote do: 123
123
iex> quote do: 3.14
3.14
iex> quote do: [1, 2, 3]
[1, 2, 3]
iex> quote do: "string"
"string"
iex> quote do: {:ok, 1}
{:ok, 1}
iex> quote do: {:ok, [1, 2, 3]}
{:ok, [1, 2, 3]}
如果我们将上述例子传递给一个宏,那么宏接受的也只会是参数的字面量形式,而不是抽象表达形式。如果 quote 其他的数据类型,我们就会看到,得到的是抽象形式:
iex> quote do: %{a: 1, b: 2}
{:%{}, [], [a: 1, b: 2]}
iex> quote do: Enum
{:__aliases__, [alias: false], [:Enum]}
上述 quoted 的演示告诉我们 Elixir 的数据类型在 AST 里面有两种不同的表现形式。一些值会直接传递,而一些复杂的数据类型会转换成 quoted 表达式。编写宏时牢记这些字面量规则是很有好处的,我们也就不会为参数到底是不是抽象格式而困惑了。
现在我们已经为理解 AST 结构打好了基础,是时候开始进行代码生成练习了,也可以验证下新知识。下一步,我们会探索如何利用 Elixir 宏系统来转换 AST。
宏:Elixir 的基本构建部件(Building Blocks)
该干干脏活了,我们看看宏到底是什么。我向你许诺过可以定制语言特性,现在我们就从重建一个 Elixir 特性开始吧。通过这个练习,我们会揭示宏的基本特性,同时看到 AST 是如何融入其中的。
重建 Elixir 的 unless 宏
我们现在假设 Elixir 语言根本没有内建 unless 结构。在大多数语言当中,我们不得不退而求其次,使用 if !表达式来替代它,而且只能无奈地接受。
对我们很幸运,Elixir 不是大多数语言。让我们定义自己的 unless 宏,利用已有的 if 作为我们实现的基础部件。宏必须定义在模块内部,我们定义一个 ControlFlow 模块。打开编辑器,创建 unless.exs 文件:
macros/unless.exs
defmodule ControlFlow do
defmacro unless(expression, do: block) do
quote do
if !unquote(expression), do: unquote(block)
end
end
end
在同一目录下打开 iex,测试一下:
iex> c "unless.exs"
[ControlFlow]
iex> require ControlFlow
nil
iex> ControlFlow.unless 2 == 5, do: "block entered"
"block entered"
iex> ControlFlow.unless 5 == 5 do
...> "block entered"
...> end
nil
我们必须要在模块未被 imported 时,在调用之前 require ControlFlow。因为宏接受 AST 形式的参数,我们可以接受任何有效的 Elixir 表达式作为 unless 的第一个参数。第二个参数,我们直接通过模式匹配获取 do/end 块,将 AST 绑定到一个变量上。一定要记住,一个宏其生命期的职责就是获取一个 AST 形式,然后返回一个 AST 形式,因此我们马上用 quote 返回了一个 AST。在 quote 内部,我们做了一个单行的代码生成,将 unless 关键字转换成了 if !表达式:
quote do
if !unquote(expression), do: unquote(block)
end
这种转换我们称之为宏展开(macro expansion)。unless 最终返回的 AST 将会于编译时,在调用者的上下文(context)中展开。在 unless 使用的任何地方,产生的代码将会包含一个 if !表达式。这里我们还使用了前面在 Math.say 中用到的 unquote 宏。
unquote
unquote 宏允许将值就地注入到 AST 中。你可以把 quote/unquote 想象成字符串中的插值。如果你创建了一个字符串,然后要将一个变量的值注入到字符串中,你会对其做插值的操作。构建 AST 也是类似的。我们用 quote 生成一个 AST(存入变量-译注),然后用 unquote 将(变量值-译注)值注入到一个外部的上下文。这样就允许外部的绑定变量,表达式或者是 block,能够直接注入到我们的 if ! 变体中。
我们来测试一下。我们使用 Code.eval_quote 来直接运行一个 AST 然后返回结果。在 iex 中输入下面这一系列表达式,然后分析每个变量在求值时有何不同:
iex> number = 5
5
iex> ast = quote do
...> number * 10
...> end
{:*, [context: Elixir, import: Kernel], [{:number, [], Elixir}, 10]}
iex> Code.eval_quoted ast
** (CompileError) nofile:1: undefined function number/0
iex> ast = quote do
...> unquote(number) * 10
...> end
{:*, [context: Elixir, import: Kernel], [5, 10]}
iex> Code.eval_quoted ast
{50, []}
在第7行我们看到第一次 quoted 的结果并没有被注入到返回的 AST 中。相反,产生了一个本地 number 引用的 AST,因此运行时抛出一个 undefined 错误。我们在第13行使用 unquote 正确地将 number 值注入到 quoted 上下文中,修复了这个问题。对最终的 AST 求值也返回了正确结果。
使用 unquote,我们的百宝箱里又多了一件元编程的工具。有了 quote 跟 unquote 的成对使用,构建 AST 时,我们就不需要再笨手笨脚的手工处理 AST 了。
宏展开
让我深入 Elixir 内部,去探寻在编译时宏到底发生了什么。当编译器遇见一个宏,就会递归地展开它,直到代码不再包含任何宏。下面这幅图描述了一个简单的 ControlFlow.unless 表达式的高阶处理流程。
这幅图片显示了编译器在遇到 AST 宏时的处理策略,就是将它展开。如果展开的代码依然包含宏,那就全部展开。这种展开递归地进行直到所有的宏都已经全部展开成他们最终的生成代码形式。现在我们想象一下编译器遇到下面这个代码块时:
ControlFlow.unless 2 == 5 do
"block entered"
end
我们知道 ControlFlow.unless 宏会生成一个 if ! 表达式,因此编译器会将代码展开成下面的样子:
if !(2 == 5) do
"block entered"
end
现在编译器又看到了一个 if 宏,然后继续展开代码。可能你还不知道,可是 Elixir 的 if 是在内部通过 case 表达式实现的一个宏。因此最终展开的代码变成了一个基本的 case 代码块:
case !(2 == 5) do
x when x in [false, nil] ->
nil
_ ->
"block entered"
end
现在代码不再包含任何可展开的宏了,编译器完成它的工作然后继续编译其它代码去了。case 宏属于一个最小规模宏集合的一员,它位于 Kernel.SpecialForms 中。这些宏属于 Elixir 的基础构建部分(building blocks),绝对不能够覆盖篡改。它们也是宏扩展的尽头。
让我们打开 iex 跟随前面的流程,看下 AST 是如何一步步展开的。我们使用 Macro.expand_once 在每一步捕获结果后展开一次。注意要在与 unless.exs 文件相同目录中打开 iex,输入下面表达式:
iex> c "macros/unless.exs"
[ControlFlow]
iex> require ControlFlow
nil
iex> ast = quote do
...> ControlFlow.unless 2 == 5, do: "block entered"
...> end
{{:., [], [{:__aliases__, [alias: false], [:ControlFlow]}, :unless]}, [],
[{:==, [context: Elixir, import: Kernel], [2, 5]}, [do: "block entered"]]}
iex> expanded_once = Macro.expand_once(ast,__ENV__)
{:if, [context: ControlFlow, import: Kernel],
[
{:!, [context: ControlFlow, import: Kernel],
[{:==, [context: Elixir, import: Kernel], [2, 5]}]},
[do: "block entered"]
]}
iex> expanded_fuly = Macro.expand_once(expanded_once,__ENV__)
{:case, [optimize_boolean: true],
[
{:!, [context: ControlFlow, import: Kernel],
[{:==, [context: Elixir, import: Kernel], [2, 5]}]},
[
do: [
{:->, [],
[
[
{:when, [],
[
{:x, [counter: -576460752303423452], Kernel},
{{:., [], [Kernel, :in]}, [],
[{:x, [counter: -576460752303423452], Kernel}, [false, nil]]}
]}
],
nil
]},
{:->, [], [[{:_, [], Kernel}], "block entered"]}
]
]
]}
iex>
第7行,我们 quote 了一个简单的 unless 宏调用。接下来,我们第13行使用 Macro.expand_once 来展开宏一次。我们可以看到 expanded_once AST 被转换成了 if ! 表达式,正如我们在 unless 中定义的。最终,在第18行我们完全将宏展开。expanded_fully AST 显示 Elixir 中的 if 宏最终完全被分解为最基础的 case 表达式。
这个练习只为展示 Elixir 宏系统构建的本质。我们三次进入代码构造,然后依赖简单的 AST 转换生成了最终结果。Elixir 中的宏一以贯之。这些宏让这门语言能够构建自身,我们自己的库也完全可以利用。
代码的多层次展开听上去不大安全,但没必要担心。Elixir 有办法保证宏运行时的安全。我们看下是如何做到的。
代码注入和调用者上下文
宏不光是为调用者生成代码,还要注入他。我们将代码注入的地方称之为上下文(context)。一个 context 就是调用者的 bindings,imports,还有 aliases 能看到的作用域。对于宏的调用者,context 非常宝贵。它能够保持你眼中世界的样貌,而且是不可变的,你可不会希望你的变量,imports,aliases 在你不知道的情况下偷偷改变吧。
Elixir 的宏在保持 context 安全性跟必要时允许直接访问两者间保持了优秀的平衡。让我们看看如何安全地注入代码,以及有何手段可以访问调用者的 context。
注入代码
因为宏全部都是关于注入代码的,因此你必须得理解宏运行时的两个 context,否则代码很可能在错误的地方运行。一个 context 是宏定义的地方,另外一个是调用者调用宏的地方。让我们实战一下,定义一个 definfo 宏,这个宏会以友好格式输出模块信息,用于显示代码执行时所在的 context。创建 callers_context.exs 文件,输入代码:
macros/callers_context.exs
defmodule Mod do
defmacro definfo do
IO.puts "In macro's context (#{__MODULE__})."
quote do
IO.puts "In caller's context (#{__MODULE__})."
def friendly_info do
IO.puts """
My name is #{__MODULE__}
My functions are #{inspect __info__(:functions)}
"""
end
end
end
end
defmodule MyModule do
require Mod
Mod.definfo
end
进入 iex,加载文件:
iex> c "callers_context.exs"
In macro's context (Elixir.Mod).
In caller's context (Elixir.MyModule).
[MyModule, Mod]
iex> MyModule.friendly_info
My name is Elixir.MyModule
My functions are [friendly_info: 0]
:ok
我们可以从标准输出看到,当模块编译时我们分别进入了宏和调用者的 context。第3行在宏展开前,我们进入了 definfo 的 context。然后第6行在 MyModule 调用者内部生成了展开的 AST,在这里 IO.puts 被直接注入到模块内部,同时还单独定义了一个 friendly_info 函数。
如果你搞不清楚你的代码当前运行在什么 context 下,那就说明你的代码过于复杂了。要避免混乱的唯一办法就是保持宏定义尽可能的简短直白。
保护调用者 Context 的卫生
(卫生宏:这个名称真难听,当初是谁第一个翻译的) Elixir 的宏有个原则要保持卫生。卫生的含义就是你在宏里面定义的变量,imports,aliases 等等根本不会泄露到调用者的空间中。在展开代码时我们必须格外注意宏的卫生,因为有时候我们万不得已还是要采用一些不那么干净的手法来直接访问调用者的空间。
当我第一次了解到卫生这个词,感觉听上去非常的尴尬和困惑--这真是用来描述代码的词吗。但若干介绍之后,这个关乎干净,无污染的执行环境的想法就完全能够理解了。这个安全机制不但能够阻止灾难性的名字空间冲突,还能迫使我们进入调用者的 context 时必须交代的清楚明白。
我们已经见识过了代码注入如何工作,但我们还没有在两个不同 contexts 间定义或是访问过变量。让我探索几个例子看看宏卫生如何运作。我们将再次使用 Code.eval_quoted 来执行一段 AST。在 iex 中输入如下代码:
iex> ast = quote do
...> if meaning_to_life == 42 do
...> "it's true"
...> else
...> "it remains to be seen"
...> end
...> end
{:if, [context: Elixir, import: Kernel],
[
{:=, [], [{:meaning_to_life, [], Elixir}, 42]},
[do: "it's true", else: "it remains to be seen"]
]}
iex> Code.eval_quoted ast, meaning_to_life: 42
** (CompileError) nofile:1: undefined function meaning_to_life/0
meaning_to_life 这个变量在我们表达式的视野中完全找不到,即便我们将绑定传给 Code.eval_quoted 也不行。Elixir 的安全策略是你必须直白地声明,允许宏在调用者的 context 中定义绑定。这种设计会强制你思考破坏宏卫生是否必要。
破坏卫生
我们可以用 var! 宏来直接声明在 quoted 表达式中需要破坏宏卫生。让我们重写之前 iex 中的例子,使用 var! 来进入到调用者的 context:
iex> ast = quote do
...> if var!(meaning_to_life) == 42 do
...> "it's true"
...> else
...> "it remains to be seen"
...> end
...> end
{:if, [context: Elixir, import: Kernel],
[
{:==, [context: Elixir, import: Kernel],
[
{:var!, [context: Elixir, import: Kernel],
[{:meaning_to_life, [], Elixir}]},
42
]},
[do: "it's true", else: "it remains to be seen"]
]}
iex> Code.eval_quoted ast, meaning_to_life: 42
{"it's true", [meaning_to_life: 42]}
iex> Code.eval_quoted ast, meaning_to_life: 100
{"it remains to be seen", [meaning_to_life: 100]}
让我创建一个模块,在其中篡改在调用者中定义的变量,看看宏的表现。在 iex 中输入如下:
macros/setter1.exs
iex> defmodule Setter do
...> defmacro bind_name(string) do
...> quote do
...> name = unquote(string)
...> end
...> end
...> end
{:module, Setter, ...
iex> require Setter
nil
iex> name = "Chris"
"Chris"
iex> Setter.bind_name("Max")
"Max"
iex> name
"Chris"
我们可以看到由于卫生机制保护着调用者的作用域,name 变量并没有被篡改。我们再试一次,使用 var! 允许我们的宏生成一段 AST,在展开时可以直接访问调用者的绑定: macros/setter2.exs
iex> defmodule Setter do
...> defmacro bind_name(string) do
...> quote do
...> var!(name) = unquote(string)
...> end
...> end
...> end
{:module, Setter, ...
iex> require Setter
nil
iex> name = "Chris"
"Chris"
iex> Setter.bind_name("Max")
"Max"
iex> name
"Max"
通过使用 var!,我们破坏了宏卫生将 name 重新绑定到一个新的值。破坏宏卫生一般用于一事一议的个案处理。当然一些高阶的手法也需要破坏宏卫生,但我们一般应该尽量避免,因为它可能隐藏实现细节,同时添加一些不为调用者所知的隐含行为。以后的练习我们会有选择的破坏卫生,但那是绝对必要的。
使用宏时,我们一定要清楚地知道宏运行在哪个 context,同时要保持宏卫生。我们体验过直接声明破坏卫生,用于探索宏在整个生命周期所进入的不同 context。我们要秉持这些信念来指导我们后续的开发实践。
进一步探索
我们已经揭开了抽象语法树的神秘面纱,它是支撑所有 Elixir 代码的基础。通过 quote 一个表达式,操纵 AST,定义宏,你的元编程之旅一路进阶。在后续的章节,我们会创建更为高级的宏,用来定制语言结构,我们还会编写一个迷你测试框架,可以推断 Elixir 表达式的含义。
至于你,需要将前面讲的知识点展开。这有一些想法你可以尝试一下:
- 不依赖 Kernel.if 定义一个 unless 宏,使用其他的 Elixir 流程控制结构。
- 定义一个宏用来返回手写代码的原始 AST,当然不准使用 quote 代码生成。