どうも、靖宗です。
Meta-programming編は少ないもので3章構成です。
ただし、各々のテーマが裏に闇を感じます。本気で相手したら時間が無限に吸い取られて行く気がします・・・
今回はDomain-specific languages。1章で終わるんでしょうか?
Foreword
DSLを実装するにあたって、マクロを使わんといかんのか?という疑問が湧きます。
実際、データをうまいこと組み合わせた構造を使うだけで終わる場合もあれば関数で実装できる場合もあります。
ここでデータのバリデーション(整合性チェック)を行う機能を考えてみましょう。データ構造を使った実装、関数を使った実装、マクロを使った実装を比較してみます。
# 1. data structures import Validator validate user, name: [length: 1..100], email: [matches: ~r/@/] # 2. functions import Validator user |> validate_length(:name, 1..100) |> validate_matches(:email, ~r/@/) # 3. macros + modules defmodule MyValidator do use Validator validate_length :name, 1..100 validate_matches :email, ~r/@/ end MyValidator.validate(user)
データ構造で実装されたものが一番柔軟性(直接読める、書ける、関数に放り込んだりできる)が高そうです。
二個目の関数で実装されたものは少々複雑なAPIなどに適合するかもしれません。オプションなどを付けたりする場合はこちらが適当でしょう。また、パイプ演算子によって可読性が高いようにも思えます。
最後のマクロで実装されたものが最も複雑といえるでしょう。行数増えてるしテストしにくいしでなんか良いところ無さそうな感じです。
裏を返せば自由度が高すぎると開発時に勝手に拡張してしまったり、特定の条件下でのみ適合して欲しいのに柔軟性が高くて適合してしまうなんてことも起こりえます。使いにくさを利用して特定の条件下でのみの動作を実装するのには良さそうです。(とはいえきちんと使えればどれでもいい気はしますが・・・)
ここではデータ構造と関数は別の入門編でいっぱいやったのでマクロで実装していきます。
Building our own test case
この章の目標は下記のような動作をするTestCase
というモジュールを作成することです。
defmodule MyTest do use TestCase test "arithmetic operations" do 4 = 2 + 2 end test "list operations" do [1, 2, 3] = [1, 2] ++ [3] end end MyTest.run
TestCase
を使用したテストはtest
マクロを使用してテストを記述し、run
で全てのテストを実行するといった動作になっています。
また通常であればassert
などでテストの実行結果を評価していましたが、ここでは=
演算子で評価しています。
The test macro
早速実装していきます。まずはtest
というマクロです。
defmodule TestCase do # Callback invoked by `use`. # # For now it returns a quoted expression that # imports the module itself into the user code. @doc false defmacro __using__(_opts) do quote do import TestCase end end @doc """ Defines a test case with the given description. ## Examples test "arithmetic operations" do 4 = 2 + 2 end """ defmacro test(description, do: block) do function_name = String.to_atom("test " <> description) quote do def unquote(function_name)(), do: unquote(block) end end end
とりあえずtest
というマクロの箇所はこれで良さそうです。試しにiexでやってみます。ファイルに上記のコードを記載してiex ファイル名.exs
でiexを起動します。
iex> defmodule MyTest do ...> use TestCase ...> ...> test "hello" do ...> "hello" = "world" ...> end ...> end iex> MyTest."test hello"() ** (MatchError) no match of right hand side value: "world"
テスト名がついた関数が生成され、テストが実行されました。
MatchErrorで例外が上がってますが、想定した動作です。
Storing information with attributes
テストを定義していちいち実行していては身が持ちません。全てのテストを集約して一気に実行したいところですが、関数の保持などはどうすればいいでしょうか。
__MODULE__.__info__(:functions)
という機能を利用すればモジュールの関数の情報を引っ張って来れそう(適当にtest
とかついてるやつを列挙すればいちおういけそう)ですが、他の情報なども保持しようと思うと大変です。
そこで、第十四章のModule attributesで紹介したAs temporary storage
としてモジュールの属性を利用します。
__using__
の箇所で属性を初期化してrun
関数で実行するように実装します。
defmodule TestCase do @doc false defmacro __using__(_opts) do quote do import TestCase # Initialize @tests to an empty list @tests [] # Invoke TestCase.__before_compile__/1 before the module is compiled @before_compile TestCase end end @doc """ Defines a test case with the given description. ## Examples test "arithmetic operations" do 4 = 2 + 2 end """ defmacro test(description, do: block) do function_name = String.to_atom("test " <> description) quote do # Prepend the newly defined test to the list of tests @tests [unquote(function_name) | @tests] def unquote(function_name)(), do: unquote(block) end end # This will be invoked right before the target module is compiled # giving us the perfect opportunity to inject the `run/0` function @doc false defmacro __before_compile__(_env) do quote do def run do Enum.each @tests, fn name -> IO.puts "Running #{name}" apply(__MODULE__, name, []) end end end end end
__using__
の箇所では@tests
の初期化以外に@before_compile
という属性が出てきました。
どうやらこれはTestCaseを利用しているモジュールをコンパイルする直前にマクロを展開する仕組みのようです。
__before_compile__
という名称で定義されたマクロが実行されるので、最後に__before_compile__
を定義しています。
全てのテストがtest
マクロで展開され、@tests
に代入された後にコンパイル時にrun
関数が実装される様になっています。
一応実行しておきます。
iex(1)> defmodule MyTest do ...(1)> use TestCase ...(1)> test "hello" do ...(1)> "hello" = "world" ...(1)> end ...(1)> end {:module, MyTest, <<70, 79, 82, 49, 0, 0, 6, 188, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 225, 0, 0, 0, 23, 13, 69, 108, 105, 120, 105, 114, 46, 77, 121, 84, 101, 115, 116, 8, 95, 95, 105, 110, 102, 111, 95, 95, ...>>, {:"test hello", 0}} iex(2)> MyTest.run Running test hello ** (MatchError) no match of right hand side value: "world" iex:4: MyTest."test hello"/0 (elixir) lib/enum.ex:769: Enum."-each/2-lists^foreach/1-0-"/2 (elixir) lib/enum.ex:769: Enum.each/2 iex(2)>
できました。
実用的にはまだまだですが、これでテストケースのDSLを開発することができたと言って良いでしょう。
あまりテストケースなど以外では使用用途がうまく思いつきませんが、以上でMeta-programming編は終了です。
Macroの機能などはもっとあるのですが、これ以上はその都度調べて行く程度でいい気がします。