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 的數量已經執行時間。
相關文章
相關標籤/搜索