Elixir元编程-第二章 使用元编程扩展 Elixir

Elixir元编程-第二章 使用元编程扩展 Elixir

宏不只仅是用于前面一章所讲的简单的转换。他们还会用于强大的代码生成,优化效率,减小模板,并生成优雅的 API。一旦你认识到大多数的 Elixir 标准库都是用宏实现的,一切皆可能,无非取决于你到底想把语言扩展到何种程度。有了它咱们的愿望清单就会逐一实现。本章就是教你如何去作。express

即将 开始咱们的旅程,咱们会添加一个全新的流程控制语句到 Elixir 中,扩展模块系统,建立一个测试框架。Elixir 将这一切的基础构建功能汇聚于咱们的指尖,让咱们开始构建把。编程

定制语言结构

你已经见识过了宏容许你在语言中添加本身的关键字,并且它还能让 Elixir 以更为灵活的方式适应将来的需求。好比,若是咱们须要语言支撑一个并行的 for 语句,咱们不用干等着,咱们为内建的 for 宏扩展一个新的 para 宏,它会经过 spawn 更多的进程来并行的运行语句。语法可能相似以下:api

para(for i <- 1..10 do: i * 10)app

para 的功能将 for 语句的 AST 转换成并行执行语句。咱们只经过添加一个 para 调用,就让 for 语句以一种全新的方式执行。José 给咱们提供了坚实的语言基础,咱们能够创造性的解决咱们的需求。框架

重写 if 宏

让咱们验证下咱们的想法。再看下前一章中的 unless 例子中的 if 宏。if 宏看上去很特殊,但咱们知道它跟其余宏也差很少。让咱们重写 Elixir 的 if 宏,体会一下用语言的构建机制实现功能有多简单。less

建立 if_recreated.exs 文件,内容以下:ide

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,加载文件,测试一下表达式:oop

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 的数量已经执行时间。
相关文章
相关标签/搜索