Elixir元编程-第二章 使用元编程扩展 Elixir
宏不仅仅是用于前面一章所讲的简单的转换。他们还会用于强大的代码生成,优化效率,减少模板,并生成优雅的 API。一旦你认识到大多数的 Elixir 标准库都是用宏实现的,一切皆可能,无非取决于你到底想把语言扩展到何种程度。有了它我们的愿望清单就会逐一实现。本章就是教你如何去做。
即将 开始我们的旅程,我们会添加一个全新的流程控制语句到 Elixir 中,扩展模块系统,创建一个测试框架。Elixir 将这一切的基础构建功能汇聚于我们的指尖,让我们开始构建把。
定制语言结构
你已经见识过了宏允许你在语言中添加自己的关键字,而且它还能让 Elixir 以更为灵活的方式适应未来的需求。比如,如果我们需要语言支撑一个并行的 for 语句,我们不用干等着,我们为内建的 for 宏扩展一个新的 para 宏,它会通过 spawn 更多的进程来并行的运行语句。语法可能类似如下:
para(for i <- 1..10 do: i * 10)
para 的功能将 for 语句的 AST 转换成并行执行语句。我们只通过添加一个 para 调用,就让 for 语句以一种全新的方式执行。José 给我们提供了坚实的语言基础,我们可以创造性的解决我们的需求。
重写 if 宏
让我们验证下我们的想法。再看下前一章中的 unless 例子中的 if 宏。if 宏看上去很特殊,但我们知道它跟其他宏也差不多。让我们重写 Elixir 的 if 宏,体会一下用语言的构建机制实现功能有多简单。
创建 if_recreated.exs 文件,内容如下:
macros/if_recreated.exs
defmodule ControlFlow do
defmacro my_if(expr, do: if_block), do: if(expr, do: if_block, else: nil)
defmacro my_if(expr, do: if_block, else: else_block) do
quote do
case unquote(expr) do
result when result in [false, nil] -> unquote(else_block)
_ -> unquote(if_block)
end
end
end
end
打开 iex,加载文件,测试一下表达式:
iex> c "if_recreated.exs"
[MyIf]
iex> require ControlFlow
nil
iex> ControlFlow.my_if 1 == 1 do
...> "correct"
...> else
...> "incorrect"
...> end
"correct"
少于十行的代码,我们用 case 重写了 Elixir 中一个主要的流程控制语句。
现在你已经体会到了 first-calss macros(作为第一阶公民的宏),让我做点更有趣的事情,创建一个全新的语言特性。我们将使用相同的技术,利用现有的宏作为构建的基础(building blocks)来实现新功能。
为 Elixir 添加 while 循环
你可能注意到了 Elixir 语言缺乏其他大多数语言中都具备的 while 循环语句。这不是什么了不得的东西,但有时没有它会有稍许不便。如果你发现你渴求某种特性很久了,要记住 Elixir 是设计成允许你自己扩展的。语言设计的比较小,因为它没必要容纳所有的通用特性。如果我们需要 while 循环,我们完全有能力自己创建他。我们来干吧。
我们会扩展 Elixir 增加一个新的 while 宏,他们循环执行,可以随时中断。下面是我们要创建的样例:
while Process.alive?(pid) do
send pid, {self, :ping}
receive do
{^pid, :pong} -> IO.puts "Got pong"
after 2000 -> break
end
end
要构建此类特性,做好从 Elixir building blocks 已提供的东西触发来实现自己的高阶目标。我们遇到的问题是 Elixir 没有内建无限循环功能。因此如何在没有此类特性的情况下完成一个反复的循环呢?我们作弊了。我们通过创建一个无限的 stream,然后通过 for 语句不断读取,以此来达到无限循环的同样目的。
创建一个 while.exs 文件。在 Loop 模块中定义一个 while 宏:
macros/while_step1.exs
defmodule Loop do
defmacro while(expression, do: block) do
quote do
for _ <- Stream.cycle([:ok]) do
if unquote(expression) do
unquote(block)
else
# break out of loop
end
end
end
end
end
开头直接就是一个模式匹配,获取表达式跟代码块。同所有宏一样,我们需要为调用者生成一个 AST,因此我们需要 quote 一段代码。然后,我们通过读取一个无限 stream 来实现无限循环,这里用到了 Stream.cycle([:ok])。在 for block 中,我们将 expression 注入到 if/else 语句中作为条件判断,控制代码块的执行。我们还没有实现 break 中断执行的功能,先不管他,在 iex 测试下,确保现在实现的功能正确。
在 iex 里执行文件,用 ctrl-c 中断循环:
iex(1)> c "while.exs"
[Loop]
iex(2)> import Loop
nil
iex(3)> while true do
...(3)> IO.puts "looping!"
...(3)> end
looping!
looping!
looping!
looping!
looping!
looping!
...
^C^C
我们的第一步已经完成。我们能够重复执行 block 中的程序。现在我们要实现 expression 一旦不为 true 后中断循环的功能。Elixir 的 for 语句没有内建中断的功能,但我们可以小心地使用 try/catch 功能, 通过抛出一个值(throw a value)来中断执行。让我们抛出一个 :break,捕获它后终止无限循环。
修改 Loop 模块如下:
macros/while_step2.exs
defmodule Loop do
defmacro while(expression, do: block) do
quote do
try do
for _ <- Stream.cycle([:ok]) do
if unquote(expression) do
unquote(block)
else
throw :break
end
end
catch
:break -> :ok
end
end
end
end
第5行,我们使用 try/catch 将 for 语句包裹住。然后第10行我们简单地抛出一个 :break,第14行捕获这个值然后中断无限循环。在 iex 测试下:
iex> c "while.exs"
[Loop]
iex> import Loop
iex> run_loop = fn ->
...> pid = spawn(fn -> :timer.sleep(4000) end)
...> while Process.alive?(pid) do
...> IO.puts "#{inspect :erlang.time} Stayin' alive!"
...> :timer.sleep 1000
...> end
...> end
#Function<20.90072148/0 in :erl_eval.expr/5>
iex> run_loop.()
{8, 11, 15} Stayin' alive!
{8, 11, 16} Stayin' alive!
{8, 11, 17} Stayin' alive!
{8, 11, 18} Stayin' alive!
:ok
iex>
现在我们有了一个全功能的 while 循环。小心的使用 throw,我们具备在 while 条件不为 true 的情况下中断执行的能力。我们再提供一个 break 函数,这样调用者就可以直接调用它来终止执行:
macros/while.exs
defmodule Loop do
defmacro while(expression, do: block) do
quote do
try do
for _ <- Stream.cycle([:ok]) do
if unquote(expression) do
unquote(block)
else
Loop.break
end
end
catch
:break -> :ok
end
end
end
def break, do: throw :break
end
第19行,我们定义了一个 break 函数,允许调用者调用它来抛出 :break。当然调用者是可以直接抛出这个值,但是我们需要为 while 宏提供一个更高阶的 break 函数抽象,便于统一终止行为。在 iex 测试下这个最终实现:
iex> c "while.exs"
[Loop]
iex> import Loop
nil
iex>
pid = spawn fn ->
while true do
receive do
:stop ->
IO.puts "Stopping..."
break
message ->
IO.puts "Got #{inspect message}"
end
end
end
#PID<0.93.0>
iex> send pid, :hello
Got :hello
:hello
iex> send pid, :ping
Got :ping
:ping
iex> send pid, :stop
Stopping...
:stop
iex> Process.alive? pid
false
我们已经为语言添加了一个完整的全新功能!我们采用了 Elixir 内部实现的相同技术,利用现有的宏作为 building blocks 完成了任务。一步步的,我们将 expression 和代码 block 转换进一个无限循环,并且可以按条件终止。
Elixir 本身全部就是基于这种扩展构建的。接下来,我们会使用 AST 内省来设计一个足够智能的断言,并创建一个迷你的测试框架。
使用宏进行智能测试
如果你对大多数主流语言的测试比较熟悉的话,你就知道需要针对每种不同的测试框架需要花点功夫学习它的断言函数。比如,我们比较下 Ruby 和 JavaScript 中比较流行的测试框架的基本断言同 Elixir 的异同。你不必熟悉这些语言;只需意会下这些不同的断言 API。
JavaScript:
expect(value).toBe(true);
expect(value).toEqual(12);
expect(value).toBeGreaterThan(100);
Ruby:
assert value
assert_equal value, 12
assert_operator value, :<=, 100
Elixir:
assert value
assert value == 12
assert value <= 100
注意到了吗,在 Ruby 和 JavaScript 中即便是非常简单的断言,其方法和函数名还是过于随意。他们可读性还是不错,但是他们狡猾地隐藏了测试表达式的真正形式。他们需要为测试框架中的断言如何表述表达式,臆造出一套模式。
这些语言之所以将方法函数设计成这个样子,是因为需要处理错误信息。比如在 Ruby 语言中 assert value <= 100 断言失败的话,你只能得到少的的可怜的信息 "expected true,got false"。如果为每个断言设计独立的函数,就能生成正确的错误信息,但是代价就是会生成一大堆庞大的测试 API 函数。而且每次你写断言的时候,脑子里都要想半天,需要用哪个函数。我们有更好的解决办法。
宏赋予了 Elixir 中 ExUnit 测试框架巨大的威力。正如你所了解的,它允许你访问任意 Elixir 表达式的内部形式。因此只需一个 assert 宏,我们就能深入到代码内部形式,从而能够提供上下文相关的失败信息。使用宏,我们就无需使用其它语言中生硬的函数和断言规则,因为我们可以直达每个表达式的真意。我们会运用 Elixir 的所有能力,编写一个智能 assert 宏,并创建一个迷你测试框架。
超强断言
我们设计的 assert 宏能够接受左右两边的表达式,用 Elixir 操作符间隔,比如 assert 1 > 0。如果断言失败,会基于测试表达式输出有用的错误信息。我们的宏会窥视断言的内部表达形式,从而打印出正确的测试输出。
下面是我们要实现的最高阶功能示例:
defmodule Test do
import Assertion
def run
assert 5 == 5
assert 2 > 0
assert 10 < 1
end
end
iex> Test.run
..
FAILURE:
Expected: 10
to be less than: 1
老规矩,我们在 iex 测试下我们的宏可能接受的几个表达式:
iex> quote do: 5 == 5
{:==, [context: Elixir, import: Kernel], [5, 5]}
iex> quote do: 2 < 10
{:<, [context: Elixir, import: Kernel], [2, 10]}
简单的数字比较也生成了非常直白的 AST。第一个参数是 atom 形式的操作符,第二个参数表示我们调用的 Kernel 函数,第三个是参数列表,左右表达式存放其中。使用这种表现形式,我们的 assert 就有了表现一切的基础。
创建 assertion.exs 文件,添加代码如下:
macros/assert_step1.exs
defmodule Assertion do
# {:==, [context: Elixir, import: Kernel], [5, 5]}
defmacro assert({operator, _, [lhs, rhs]}) do
quote bind_quoted: [operator: operator, lhs: lhs, rhs: rhs] do
Assertion.Test.assert(operator, lhs, rhs)
end
end
end
我们在输入的 AST 表达式上直接进行模式匹配,格式就是在前面 iex 中看到的。然后,我们生成了一个单行代码,使用我们模式匹配后绑定的变量,代理到 Assertion.Test.assert 函数,这个函数随后实现。这里,我们第一次使用了 bind_quoted。在继续编写 assert 宏之前,我们先研究下 bind_quoted 是什么。
bind_quoted
quote 宏可以带 bind_quoted 参数,用来将绑定变量传递给 block,这样可以确保外部绑定的变量只会进行一次 unquoted。我们使用过不带 bind_quoted 参数的 quote 代码块,最佳实践建议随时都使用它,这样可以避免不可预知的绑定变量的重新求值。下面两段代码是等价的:
quote bind_quoted: [operator: operator, lhs: lhs, rhs: rhs] do
Assertion.Test.assert(operator, lhs, rhs)
end
quote do
Assertion.Test.assert(unquote(operator), unquote(lhs), unquote(rhs))
end
我们这里使用 bind_quoted 没有额外的好处,我们来看下一个不同的例子,就知道为什么推荐要用它了。假设我们构建了一个 Debugger.log 宏,用来执行一个表达式,但要求只有在 debug 模式下才调用 IO.inspect 输出结果。
输入下列代码,文件存为 debugger.exs:
macros/debugger.exs
defmodule Debugger do
defmacro log(expression) do
if Application.get_env(:debugger, :log_level) == :debug do
quote do
IO.puts "================="
IO.inspect unquote(expression)
IO.puts "================="
unquote(expression)
end
else
expression
end
end
end
我们定义了一个简单 Debugger.log 宏,他接受一个表达式。如果配置编译时的 :log_level 为 :debug,那么在第6行会输出表达式的调试信息。然后在第8行正式执行表达式。我们在 iex 里测试一下:
iex> c "debugger.exs"
[Debugger]
iex> require Debugger
nil
iex> Application.put_env(:debugger, :log_level, :debug)
:ok
iex> remote_api_call = fn -> IO.puts("calling remote API...") end
#Function<20.90072148/0 in :erl_eval.expr/5>
iex> Debugger.log(remote_api_call.())
=================
calling remote API...
:ok
=================
calling remote API...
:ok
iex>
看到没有,remote_api_call.() 表达式被调用了两次!这是因为在 log 宏里面我们对表达式做了两次 unquoted。让我们用 bind_quoted 修正这个错误。
修改 debugger.exs 如下:
defmodule Debugger do
defmacro log(expression) do
if Application.get_env(:debugger, :log_level) == :debug do
quote bind_quoted: [expression: expression] do
IO.puts "================="
IO.inspect expression
IO.puts "================="
expression
end
else
expression
end
end
end
我们修改 quote block,带上 bind_quoted 参数,于是 expression 就会一次性 unquoted 后绑定到一个变量上。现在在 idex 再测试下:
iex> c "debugger_fixed.exs"
[Debugger]
iex> Debugger.log(remote_api_call.())
calling remote API...
=================
:ok
=================
:ok
iex>
现在我们的函数调用就只会执行一次了。使用 bind_quoted 就可以避免无意中对变量重新求值。同时在注入绑定变量时也无需使用 unquote 了,这样 quote block 也看上去更整洁。要注意的一点是,当使用 bind_quoted 时 unquote 会被禁用。你将无法再使用 unquote 宏,除非你给 quote 传入一个 unquote: true 参数。现在我们知道 bind_quoted 的工作机制了,继续进行我们的 Assertion 框架。
Leveraging the VM’s Pattern Matching Engine
现在我们的 assert 宏已经就绪了,我们可以编写 Assertion.Test 模块中 assert 代理函数了。Assertion.Test 模块负责运行测试,执行断言。当你编写代码将任务代理至外部函数,想一下怎么可以通过模式匹配来简化实现。我们看看怎样尽可能把任务丢给虚拟机,以保持我们的代码清晰简洁。
更新 assertion.exs 文件如下:
macros/assert_step2.exs
defmodule Assertion do
defmacro assert({operator, _, [lhs, rhs]}) do
quote bind_quoted: [operator: operator, lhs: lhs, rhs: rhs] do
Assertion.Test.assert(operator, lhs, rhs)
end
end
end
defmodule Assertion.Test do
def assert(:==, lhs, rhs) when lhs == rhs do
IO.write "."
end
def assert(:==, lhs, rhs) do
IO.puts """
FAILURE:
Expected: #{lhs}
to be equal to: #{rhs}
"""
end
def assert(:>, lhs, rhs) when lhs > rhs do
IO.write "."
end
def assert(:>, lhs, rhs) do
IO.puts """
FAILURE:
Expected: #{lhs}
to be greater than: #{rhs}
"""
end
end
第5行代码将任务代理至 Assertion.Test.assert后,我们让虚拟机的模式匹配接管任务,输出每个断言的结果。同时我们将函数放到新的 Test 模块下,这样当 import Assertion 时,这些函数不会泄露到调用者模块中。我们只希望调用者 import Assertion 里面的宏,因此我们派发任务到另一个模块中,以避免 import 过多不必要的函数。
这也是实现高效宏的一大原则,目标是在调用者上下文中尽可能少的生成代码。通过将任务代理到外部函数,我们可以尽可能的保持代码的清晰直白。随后你将看到,这个方法是保证编写的宏可维护的关键。
我们为每一种 Elixir 操作符编写一段 Assertion.Test.assert 定义,用来执行断言,关联相关的错误信息。首先,我们在 idex 探索下当前实现。测试几个断言:
iex> c "assertion.exs"
[Assertion.Test, Assertion]
iex> import Assertion
nil
iex> assert 1 > 2
FAILURE:
Expected: 1
to be greater than: 2
:ok
iex> assert 5 == 5
.:ok
iex> assert 10 * 10 == 100
.:ok
为了更方便测试,我们编写一个 MathTest 模块,执行一些断言,模拟下模块测试:
macros/math_test_import.exs
defmodule MathTest do
import Assertion
def run do
assert 5 == 5
assert 10 > 0
assert 1 > 2
assert 10 * 10 == 100
end
end
iex> MathTest.run
..FAILURE:
Expected: 1
to be greater than: 2
.:ok
我们的测试框架已经初具规模,但是还有一个问题。强制用户实现自己的 run/0 函数实在是太不方便了。如果能提供一种方法通过名称或描述成组地进行测试就太好了。
下一步,我们会扩展我们简陋的 assert 宏,创建一门测试用 DSL。领域专用语言(DSL)将在第五章全面讲解,现在我们只是初步尝试一下。
扩展模块
宏的核心目标是注入代码到模块中,以增强其行为,为其定义函数,或者生成它需要的任何代码。对于我们的 Assertion 框架,我们的目标是通过 test 宏扩展其他模块。宏将接受一个字符串作为测试案例的描述信息,后面再跟上一个代码块用于放置断言。错误信息前面会带上描述,便于调试是哪个测试案例失败了。我们还会自动为调用者定义 run/0 函数,这样所有的测试案例就可以通过一个函数调用一次搞定。
这一章节我们的目标就是生成如下的测试 DSL,我们的测试框架会在任意模块中扩展。看下这些代码,但还不要着急输入:
defmodule MathTest do
use Assertion
test "integers can be added and subtracted" do
assert 1 + 1 == 2
assert 2 + 3 == 5
assert 5 - 5 == 10
end
test "integers can be multiplied and divided" do
assert 5 * 5 == 25
assert 10 / 2 == 5
end
end
iex> MathTest.run
..
===============================================
FAILURE: integers can be added and subtracted
===============================================
Expected: 0
to be equal to: 10
..:ok
在第2行,我们第一次看到了 use。后面会详细讲解。整理下我们的测试目标,我们需要为 Assertion 模块提供一种方法,可以在调用者上下文中生成一堆代码。在我们这里例子中,我们需要在用户模块的上下文中,自动为用户定义 run/0 函数,这个函数用于执行测试案例。开始工作吧。
模块扩展就是简单的代码注入
Elixir 中大多数的元编程都是为其他模块添加额外的功能。前面一章我们已经简单的体验过了模块扩展,现在我们学习它的全部内容。
我们探索下如何仅仅利用现有的知识来扩展模块。在这个过程中,你会更好地理解 Elixir 模块扩展的内部原理。
让我们编写一个 extend 宏,它可在另一个模块的上下文中注入一个 run/0 桩函数的定义。创建一个 module_extension_custom.exs 文件,输入如下代码:
defmodule Assertion do
# ...
defmacro extend(options \\ []) do
quote do
import unquote(__MODULE__)
def run do
IO.puts "Running the tests..."
end
end
end
# ...
end
defmodule MathTest do
require Assertion
Assertion.extend
end
在 iex 里运行:
iex> c "module_extension_custom.exs"
[MathTest]
iex> MathTest.run
Running the tests...
:ok
第3行,我们通过 Assertion.extend 宏直接往 MathTest 模块里面注入了一个 run/0 桩函数。Assertion.extend 就是一个普通的宏,它返回一段包含 run/0 定义的 AST。这个例子实际上也阐述了 Elixir 代码内部构成的基础。有 defmacro 跟 quote 就足够了,不需要其他手段,我们就可以在另外一个模块中定义一个函数。
use:模块扩展的通用 API
有一件事你可能早就注意到了,在很多 Elixir 库中我们会经常看到 use SomeModule 这样的语法。你可能早就在你的项目中敲过很多次了,尽管你不完全理解它干了些什么。use 宏目的很简单但也很强大,就是为模块扩展提供一个通用 API。use SomeModule 不过就是简单的调用 SomeModule.__using__/1
宏而已。为模块扩展提供标准 API,因此这个小小的宏将成为元编程的中心,贯穿本书我们会反复用到它。
让我们用 use 重写前面的代码,充分发挥这个 Elixir 标准扩展 API 的威力。更新 module_extension_custom.exs 文件,代码如下:
defmodule Assertion do
# ...
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)
def run do
IO.puts "Running the tests..."
end
end
end
# ...
end
defmodule MathTest do
use Assertion
end
测试一下:
iex> MathTest.run
Running the tests...
:ok
第3到16行,我们利用 Elixir 的标准 API use 和 __using__
来扩展 MathTest 模块。结果同我们之前的代码一样,但使用 Elixir 的标准 API 后程序更地道,代码也更灵活,易于扩展。
use 宏看上去很像一个关键字,但它不过是一个宏而已,能够做一些代码注入,就像你自己的扩展定义一样。事实就是 use 不过是个普通的宏,但这正好印证了 Elixir 所宣称的自己不过是一门构建在宏上的小型语言而已。有了我们的 run/0 桩函数,我们可以继续编写 test 宏了。
使用模块属性进行代码生成
在我们进一步编写 test 宏时,我们还需要补上缺失的一环。一个用户可能定义有多个测试案例,但是我们没法跟踪每个测试案例的定义,从而将其纳入到 MathTest.run/0 函数中。幸运的是,Elixir 用模块属性解决了这个问题。
模块属性允许在编译时将数据保存在模块中。这个特性本来用来替代其他语言中的常量的,但 Elixir 提供了更多的技巧。注册一个属性时,我们加上 accumulate:true 选项,我们就会在编译阶段,在中保留一个可追加的 list,其中包含所有的 registrations。在模块编译后,这个包含所有 registrations 的属性就会浮出水面,供我们使用。让我们看看怎样将这个特性用到我们的 test 宏中。
我们的 test 宏接受两个参数:一个字符串描述信息,其后是一个 do/end 代码块构成的关键字列表。在 assertion.exs 文件中将下面代码添加到原始的 Assertion 模块的顶部:
macros/accumulated_module_attributes.exs
defmodule Assertion do
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)
Module.register_attribute __MODULE__, :tests, accumulate: true
def run do
IO.puts "Running the tests (#{inspect @tests})"
end
end
end
defmacro test(description, do: test_block) do
test_func = String.to_atom(description)
quote do
@tests {unquote(test_func), unquote(description)}
def unquote(test_func)(), do: unquote(test_block)
end
end
# ...
end
在第6行,我们注册了一个 tests 属性,设置 accumulate 为 true。第8行,我们在 run/0 函数中查看下 @tests 属性。然后我们定义了一个 test 宏,它首先将测试案例的描述字符串转换成 atom,并用它作为后面的函数名。15到18行,我们在调用者的上下文中生成了一些代码,然后关闭宏。首先我们做到了将 test_func 引用和 description 保存到 @tests 模块属性中。
我们还完成了定义函数,函数名就是转换成的 atom 的描述信息,函数体就是 test 案例中 do/end 块之间的东西。我们新的宏还为调用者留下了一个累加列表作为测试元数据,因此我们可以定义函数执行全部测试了。
先试下我们的程序对不对。创建 math_test_step1.exs 模块,输入如下代码:
macros/math_test_step1.exs
defmodule MathTest do
use Assertion
test "integers can be added and subtracted" do
assert 1 + 1 == 2
assert 2 + 3 == 5
assert 5 - 5 == 10
end
end
在 iex 里运行一下:
iex> c "assertion.exs"
[Assertion.Test, Assertion]
iex> c "math_test_step1.exs"
[MathTest]
iex> MathTest.__info__(:functions)
["integers can be added and subtracted": 0, run: 0]
iex> MathTest.run
Running the tests ([])
:ok
怎么回事?上面显示我们的 @tests 模块属性是空的,实际上在 test 宏里面它已经正确地累加了。如果我们重新分析下 Assertion 模块的__using__
块,就能发现问题了:
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)
Module.register_attribute __MODULE__, :tests, accumulate: true
def run do
IO.puts "Running the tests (#{inspect @tests})"
end
end
end
run/0 所在的位置说明了问题。我们是在注册完 tests 属性后马上就定义的这个函数。在我们使用 use Assertion 声明时,run 函数已经在 MathTest 模块中展开了。结果导致在 MathTest 模块中,还没有任何 test 宏被注册累加的时候 run/0 已经展开完毕了。我们必须想个办法延迟宏的展开,直到某些代码生成工作完成。为了解决这个问题 Elixir 提供了 before_compile 钩子。
编译时的钩子
Elixir 允许我们设置一个特殊的模块属性,@before_compile,用来通知编译器在编译结束前还有一些额外的动作需要完成。@before_compile 属性接受一个模块名作为参数,这个模块中必须包含一个 __before_compile__/1
宏定义。这个宏在编译最终完成前调用,用来做一些收尾工作,生成最后一部分代码。让我用这个 hook 来修正我们的 test 宏。更新 Assertion 模块,添加@before_compile hooks:
macros/before_compile.exs
defmodule Assertion do
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)
Module.register_attribute __MODULE__, :tests, accumulate: true
@before_compile unquote(__MODULE__)
end
end
defmacro __before_compile__(_env) do
quote do
def run do
IO.puts "Running the tests (#{inspect @tests})"
end
end
end
defmacro test(description, do: test_block) do
test_func = String.to_atom(description)
quote do
@tests {unquote(test_func), unquote(description)}
def unquote(test_func)(), do: unquote(test_block)
end
end
# ...
end
现在在 iex 中测试:
iex> c "assertion.exs"
[Assertion.Test, Assertion]
iex> c "math_test_step1.exs"
[MathTest]
iex> MathTest.run
Running the tests (["integers can be added and subtracted":
"integers can be added and subtracted"])
:ok
起作用了!第7行,我们注册了一个 before_compile 属性用来钩住 Assert.__before_compile__/1
,然后在 MathTest 最终完成编译前将其唤醒调用。这样在第14行 @tests 属性就能正确的展开了,因为此时它是在所有测试案例注册完毕后才引用的。
要最终完成我们的框架,我们还需要实现 run/0 函数,用它来枚举累加进 @tests 中的测试案例,对其逐个运行。下面是最终代码,包含了 run/0 定义。让我们看下各部分如何有机地整合在一起:
macros/assertion.exs
defmodule Assertion do
defmacro __using__(_options) do
quote do
import unquote(__MODULE__)
Module.register_attribute __MODULE__, :tests, accumulate: true
@before_compile unquote(__MODULE__)
end
end
defmacro __before_compile__(_env) do
quote do
def run, do: Assertion.Test.run(@tests, __MODULE__)
end
end
defmacro test(description, do: test_block) do
test_func = String.to_atom(description)
quote do
@tests {unquote(test_func), unquote(description)}
def unquote(test_func)(), do: unquote(test_block)
end
end
defmacro assert({operator, _, [lhs, rhs]}) do
quote bind_quoted: [operator: operator, lhs: lhs, rhs: rhs] do
Assertion.Test.assert(operator, lhs, rhs)
end
end
end
defmodule Assertion.Test do
def run(tests, module) do
Enum.each tests, fn {test_func, description} ->
case apply(module, test_func, []) do
:ok -> IO.write "."
{:fail, reason} -> IO.puts """
===============================================
FAILURE: #{description}
===============================================
#{reason}
"""
end
end
end
def assert(:==, lhs, rhs) when lhs == rhs do
:ok
end
def assert(:==, lhs, rhs) do
{:fail, """
Expected: #{lhs}
to be equal to: #{rhs}
"""
}
end
def assert(:>, lhs, rhs) when lhs > rhs do
:ok
end
def assert(:>, lhs, rhs) do
{:fail, """
Expected: #{lhs}
to be greater than: #{rhs}
"""
}
end
end
第13行,我们在使用模块的上下文(调用者的上下文)中生成 run/0 函数定义,它会在最终编译结束前,@tests 模块属性已经累加了所有的测试元数据后再运行。它会简单地将任务代理至第33-46行的 Assertion.Text.run/2 函数。我们重构 Assertion.Test.assert 定义,不在直接输出断言结果,而是返回 :ok 或者{:fail.reason}。这样便于 run 函数根据测试结果汇总报告,也便于未来的进一步扩展。在比对下原始的 assert 宏,我们的 run/0 函数将任务代理到外部函数,这样在调用者的上下文中就可以尽可能少地生成代码。我们看下实际运用:
macros/math_test_final.exs
defmodule MathTest do
use Assertion
test "integers can be added and subtracted" do
assert 2 + 3 == 5
assert 5 - 5 == 10
end
test "integers can be multiplied and divided" do
assert 5 * 5 == 25
assert 10 / 2 == 5
end
end
iex> MathTest.run
.
===============================================
FAILURE: integers can be added and subtracted
===============================================
Expected: 0
to be equal to: 10
我们已经创建了一个迷你测试框架,综合运用了模式匹配,测试 DSL,编译时 hooks 等高阶代码生成技术。最重要的是,我们生成的代码严谨负责:我们的宏扩展非常简洁,而且通过将任务转派到外部函数,使我们的代码尽可能的简单易读。你可能会好奇又如何测试宏本身呢,我们会在第四章详述。
进一步探索
我们从一个最简单的流程控制语句一路探索到一个迷你测试框架。一路走来,你学到了所有必需的手段,能够定义自己的宏,能够严谨地进行 AST 转换。下一步,我们会探索一些编译时的代码生成技术,以此创建高性能和易维护的程序。
就你而言,你可以探索进一步增强 Assertion 测试框架,或者定义一些新的宏结构。下面这些建议你可以尝试:
- 为 Elixir 中的每一个操作符都实现 assert。
- 添加布尔型断言,比如 assert true。
- 实现一个 refute 宏,实现反向断言。
更近一步,还可以尝试:
- 通过 spawn 进程在 Assertion.Test.run/2 中并行运行测试案例。
- 为模块添加更多报告信息。包括 pass/fail 的数量已经执行时间。