もやもやエンジニア

IT系のネタで思ったことや技術系のネタを備忘録的に綴っていきます。フロント率高め。

Elixir 入門 ~ META-PROGRAMMING IN ELIXIR ~ その2 - Macro

Macros

Foreword

  • Macroは通常のElixirのコードよりもわかりづらい。開発者は責任を持ってクリーンなコードを書くように心がける。
  • Elixirはすでに簡潔でわかりやすい仕組みを提供している。Macroを使うのは最後の手段であると理解すべきだろう。

Our first macro

  • Macroはdefmacro/2で定義される。macros.exsを作ってみよう。
defmodule Unless do
  def fun_unless(clause, expression) do
    if(!clause, do: expression)
  end

  defmacro macro_unless(clause, expression) do
    quote do
      if(!unquote(clause), do: unquote(expression))
    end
  end
end
  • iex macros.exs で作成したモジュールと使ってiexを開始して、以下のコマンドを投げてみる。
iex> require Unless
nil
iex> Unless.macro_unless true, IO.puts "this should never be printed"
nil
iex> Unless.fun_unless true, IO.puts "this should never be printed"
"this should never be printed"
nil
  • fun_unless!trueの評価にもかかわらず文字列が表示されている。これはunlessが呼ばれる前にすでにIO.puts ~が評価されているためである。もう少し詳しく見てみる。

  • Unless.macro_unless true, IO.puts "this should never be printed"マクロ が呼ばれた時、macro_unless は以下を受け取っている。

  • Unless.macro_unless(true, {{:., [], [{:aliases, [], [:IO]}, :puts]}, [], ["this should never be printed"]})
  • そしてMacroから返される式は以下になる。
{:if, [], [
  {:!, [], [true]},
  {{:., [], [IO, :puts], [], ["this should never be printed"]}}]}
  • Macro.expand_once/2を使って確認してみよう。
iex> expr = quote do: Unless.macro_unless(true, IO.puts "this should never be printed")
iex> res  = Macro.expand_once(expr, __ENV__)
iex> IO.puts Macro.to_string(res)
if(!true) do
  IO.puts("this should never be printed")
end
:ok
  • Macro.expand_once/2quoted expressionを受け取り、現在の環境に合わせて拡張した結果を返す。
  • このようにMacroはquoted expressionを受け取り何かに変換して返す。実のところunless/2, defmacro/2, def/2, defprotocol/2のようなElixirのコンストラクタはすべてマクロである。すなわち、開発者の環境に応じて拡張されている。
  • 我々はどのような関数やMacroもオーバーライドできる。ただし、Kernel.SpecialFormsは例外で、拡張はできない。

Macros hygiene

  • ElixirのMacroは遅延評価(late resolutionはこの訳でいいのか。。。)を持っている。これはquoted expressionの中の変数とMacroが拡張されるときの変数がコンフリクトしないことを保証している。以下の例を見てみる。
defmodule Hygiene do
  defmacro no_interference do
    quote do: a = 1
  end
end

defmodule HygieneTest do
  def go do
    require Hygiene
    a = 13
    Hygiene.no_interference
    a
  end
end
HygieneTest.go
# => 13
  • no_interference/0の中でaに値を束縛しているが、requireしているTestモジュールのaには影響を与えていない。
  • 次のようにするとどうだろうか。
defmodule Hygiene do
  defmacro interference do
    quote do: var!(a) = 1
  end
end

defmodule HygieneTest do
  def go do
    require Hygiene
    a = 13
    Hygiene.interference
    a
  end
end
HygieneTest.go
# => 1
  • Macro内のavar!で囲むとgo/0内のaに値を再束縛している。

The environment

  • Macro.expand_once/2 を使った時に引数に __ENV__ を指定したが、これはなんだろうか。
  • __ENV__ は、現在のモジュールに関する様々な情報が格納された Macro.Env 構造体のインスタンスを返す。
iex> __ENV__.module
nil
iex> __ENV__.file
"iex"
iex> __ENV__.requires
[IEx.Helpers, Kernel, Kernel.Typespec]
iex> require Integer
nil
iex> __ENV__.requires
[IEx.Helpers, Integer, Kernel, Kernel.Typespec]

Private macros

  • ElixirはPrivate Macroもサポートしている。モジュール内がスコープになるMacroだが、注意しなければならないことがある。
  • Macroは使う前に定義されていなければならない。例えば以下のような場合は、Macroを定義する前に使っているのでエラーになる。
iex> defmodule Sample do
...>  def four, do: two + two
...>  defmacrop two, do: 2
...> end
** (CompileError) iex:2: function two/0 undefined

Write macros responsibly

  • Macroは強力で、ElixirはMacroを責任を持って使うための仕組みを提供している。
  • Macros are hygienic
    • Macroの中で定義された変数はユーザーコードに影響されない。
    • さらにMacroの中での関数呼び出しや、aliasはユーザーのコンテキストを汚染しない
  • Macros are lexical
    • Macroはグローバル空間に注入することはできない。Macroはモジュールの中でrequireあるいはimportしないと使えない。
  • Macros are explicit
    • Macroは明示的に実装されていなければ動かすことができない。
    • Macroはコンパイルの間に明示的にもたらされなければならない。
  • Macros’ language is clear
    • 多くの言語はquoteunquoteのためにシンタックスショートカットを提供している。ElixirはMacroの定義やquoted expressionとの境界を明確にするために、別に明示的に書くようにしている。
  • 上記の仕組みを持ったとしても、開発者は大きな責任を負う。もしMacroを使う場合は、MacroはあなたのAPIではないということを覚えておこう。Macroの定義は短く保つべきである。次の例を見てみよう。
defmodule MyModule do
  defmacro my_macro(a, b, c) do
    quote do
      do_this(unquote(a))
      ...
      do_that(unquote(b))
      ...
      and_that(unquote(c))
    end
  end
end
  • これを次のように書き換える
defmodule MyModule do
  defmacro my_macro(a, b, c) do
    quote do
      # Keep what you need to do here to a minimum
      # and move everything else to a function
      do_this_that_and_that(unquote(a), unquote(b), unquote(c))
    end
  end

  def do_this_that_and_that(a, b, c) do
    do_this(a)
    ...
    do_that(b)
    ...
    and_that(c)
  end
end
  • Macro内のコードが明確でテストしやすくなった。テストを書く場合はdo_this_that_and_that/3を直接呼べばよい。