Elixir の DSL がどう実装されているか、マクロの展開順を追ってみた(Plug.Builder を例に)

  • 49
    Like
  • 0
    Comment
More than 1 year has passed since last update.

はじめに

Elixir の Plug は、モジュールを組み合わせるために以下の DSL を提供している:

defmodule MyModule do
  use Plug.Builder

  plug :hello
  plug :world, good: :morning
end

use Plug.Builder したモジュール内で plug :atom, [optional args] と書くことで、モジュールに他の plug を組み込むことができる。

マクロを駆使して実装された plug の中身がどうなっているのか、 Plug.Builder を再実装しながら追ってみることにした。

マクロの参考資料

MyPlugBuilder の概要

Plug.Builder を丸ごと再実装するのは骨が折れるので、 plug マクロだけを提供するように単純化したモジュール MyPlugBuilder を作ることにする。

MyPlugBuilder は以下のように使う:

MyPlugBuilder-sample.ex
defmodule MyModule do
  use MyPlugBuilder

  plug :hello
  plug :world, good: :morning
end

IO.puts "plugs >>> #{inspect MyApp.plugs}"
# => plugs >>> [world: [good: :morning], hello: []]

defmodule 内で use MyPlugBuilder すると、 Plug.Builder に似た plug マクロを使えるようになる。同じ defmodule 内でなら何回でも plug を書ける。

定義したモジュールには plugs 関数が生える。この関数は、 plug に与えた atom とオプション引数のタプルをリストにして返す。

Plug.Builder はこのリストを元にモジュールを組み合わせていくのだが、今回はその処理までは再現しないことにする。

MyPlugBuilder を実装する

実装の過程は省くが、 MyPlugBuilder の実装は以下のようになる。

MyPlugBuilder.ex
defmodule MyPlugBuilder do

  # `use MyPluginBuilder` すると呼ばれる
  defmacro __using__(_opts) do
    quote do
      import MyPlugBuilder, only: [plug: 1, plug: 2]
      Module.register_attribute(__MODULE__, :plugs, accumulate: :true)
      @before_compile MyPlugBuilder
    end
  end

  # `plug` 本体
  defmacro plug(plug, opts \\ []) do
    quote do
      @plugs {unquote(plug), unquote(opts)}
    end
  end

  # `@before_compile MyPlugBuilder` 属性を含む翻訳単位がコンパイルされる直前に呼ばれる
  defmacro __before_compile__(env) do
    plugs = Module.get_attribute(env.module, :plugs)
    quote do
      def plugs, do: unquote(plugs)
    end
  end
end

この MyPlugBuilder を use して plug を使うとマクロがどのように展開されていくか、順を追って見ていくことにしよう。

マクロの展開を追う

1. 初期状態

上で書いた MyPlugBuilder の使い方を再掲する。ここから1段階ずつマクロを展開していく。

1
defmodule MyModule do
  use MyPlugBuilder

  plug :hello
  plug :world, good: :morning
end

2. use MyPlugBuilder マクロを展開する

use は Elixir の組み込みマクロである。 use MyPlugBuilderrequire MyPlugBuilder に展開される。また、指定したモジュールが __using__/1 を定義しているなら、 use に与えたオプション引数リストを引数にしてそれを呼ぶ。

2
defmodule MyModule do
  # ----
  # use MyPlugBuilder
  # ---- ↓
  require MyPlugBuilder
  MyPlugBuilder.__using__([])
  # ----

  plug :hello
  plug :world, good: :morning
end

3. MyPlugBuilder.__using__ マクロを展開する

MyPlugBuilder.__using__ マクロは以下のように定義したのだった:

MyPlugBuilder.__using__
  defmacro __using__(_opts) do
    quote do
      import MyPlugBuilder, only: [plug: 1, plug: 2]
      Module.register_attribute(__MODULE__, :plugs, accumulate: :true)
      @before_compile MyPlugBuilder
    end
  end

マクロは関数と似ているが、マクロの返り値は Elixir の式の内部表現でなければならない。式の内部表現は quote do 式 end で得ることができる(「式を quote する」という)。マクロが返した内部表現はマクロを呼び出した側に埋め込まれる。

MyPlugBuilder.__using__ マクロは単に quote された式を返す。それを呼び出し側に埋め込むとこうなる:

3
defmodule MyModule do
  require MyPlugBuilder

  # ----
  # MyPlugBuilder.__using__([])
  # ---- ↓
  import MyPlugBuilder, only: [plug: 1, plug: 2]
  Module.register_attribute(__MODULE__, :plugs, accumulate: :true)
  @before_compile MyPlugBuilder
  # ----

  plug :hello
  plug :world, good: :morning
end

import によって defmodule MyModule ブロック内で plug マクロを使えるようになる。

また Module.register_attribute によって、 defmodule ブロック内の @plugs 属性に指定された値を後で参照できるようになる。

@before_compile 属性のはたらきは後述する。

4. plug マクロを展開する

plug マクロの定義はこうだ:

plug
  defmacro plug(plug, opts \\ []) do
    quote do
      @plugs {unquote(plug), unquote(opts)}
    end
  end

全体を囲う quote の中で、一部を unquote している。 unquote(plug) はマクロの引数をこの場所にそのまま埋め込むという意味だ。 plug マクロを展開すると結局 @plugs 属性になる:

4
defmodule MyModule do
  require MyPlugBuilder

  import MyPlugBuilder, only: [plug: 1, plug: 2]
  Module.register_attribute(__MODULE__, :plugs, accumulate: :true)
  @before_compile MyPlugBuilder

  # ----
  # plug :hello
  # plug :world, good: :morning
  # ---- ↓
  @plugs {:hello, []}
  @plugs {:world, [good: :morning]}
  # ----
end

5. MyPlugBuilder.__before_compile__ マクロを注入する

importrequire はスペシャルフォームマクロ(処理系に特別扱いされるマクロ)だ。普通のマクロのようには展開されないので、ここでは中身に深入りせず、展開が終わったということにしておく。

さて、すべてのマクロの展開が終わったので MyModule モジュールのコンパイルが始まる。だがその前にコンパイラは @before_compile MyPlugBuilder の処理を行う。

@before_compile 属性にモジュールを指定したとき、コンパイラはまずそのモジュールの __before_comiple__/1 マクロを呼び出す。引数は __ENV__ となる。そしてその返り値を @before_compile を含むモジュールの末尾に注入する。(参考: http://elixir-lang.org/docs/stable/elixir/Module.html

言い替えれば、モジュール内のどこかに @before_compile MyPlugBuilder を書けば、モジュール末尾に MyPlugBuilder.__before_compile__(__ENV__) を書いたのと同じということだ:

5
defmodule MyModule do
  require MyPlugBuilder

  import MyPlugBuilder, only: [plug: 1, plug: 2]
  Module.register_attribute(__MODULE__, :plugs, accumulate: :true)
  # この属性によって……
  @before_compile MyPlugBuilder

  @plugs {:hello, []}
  @plugs {:world, [good: :morning]}

  # この位置に以下を書いたのと同じことになる
  MyPlugBuilder.__before_compile__(__ENV__)
end

6. MyPlugBuilder.__before_compile__ マクロを展開する

MyPlugBuilder.__before_compile__ の定義はこうだ:

MyPlugBuilder.__before_compile__
  defmacro __before_compile__(env) do
    plugs = Module.get_attribute(env.module, :plugs)
    quote do
      def plugs, do: unquote(plugs)
    end
  end

Module.get_attribute によって、 env.module の(つまり MyModule の) @plugs 属性に指定された値を得ておく。このマクロはその値を返す plugs 関数に展開される:

6
defmodule MyModule do
  require MyPlugBuilder

  import MyPlugBuilder, only: [plug: 1, plug: 2]
  Module.register_attribute(__MODULE__, :plugs, accumulate: :true)
  @before_compile MyPlugBuilder

  @plugs {:hello, []}
  @plugs {:world, [good: :morning]}

  # ----
  # MyPlugBuilder.__before_compile__(__ENV__)
  # ---- ↓
  def plugs, do: [world: [good: :morning], hello: []]
  # ----
end

7. コンパイル終了

モジュールをコンパイルすると属性は取り除かれる。最終的に MyModule は以下の形になる:

7
defmodule MyModule do
  require MyPlugBuilder

  import MyPlugBuilder, only: [plug: 1, plug: 2]
  Module.register_attribute(__MODULE__, :plugs, accumulate: :true)

  def plugs, do: [world: [good: :morning], hello: []]
end

おわりに

マクロを駆使することで以下のようにコードを変換できることが分かった:

Result
defmodule MyModule do
  use MyPlugBuilder

  plug :hello
  plug :world, good: :morning
end

# ↓↓↓

defmodule MyModule do
  require MyPlugBuilder

  import MyPlugBuilder, only: [plug: 1, plug: 2]
  Module.register_attribute(__MODULE__, :plugs, accumulate: :true)

  def plugs, do: [world: [good: :morning], hello: []]
end

MyPlugBuilder は簡単のために plug マクロを def plugs に変換するだけとしたが、 Plug.Builder はモジュールを組み合わせるために追加の処理を行っている。とは言え、肝心の plug マクロの展開の流れは MyPlugBuilder で十分に再現できたと思う。

感想

マクロは面白いけど使いたくない。