技術メモ

プログラミングとか電子工作とか

Elixir入門(Meta-programming編 第3章 Domain-specific languages)

f:id:ysmn_deus:20190122112104p:plain

どうも、靖宗です。
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の機能などはもっとあるのですが、これ以上はその都度調べて行く程度でいい気がします。