Elixirメタプログラミング 02: マクロ

gumitech

gumi TECH

Posted on December 20, 2019

Elixirメタプログラミング 02: マクロ

本稿はElixir公式サイトの許諾を得て「Macros」の解説にもとづき、加筆補正を加えて、Elixirにおけるマクロの定義方法についてご説明します。

はじめに

Elixirには、マクロをできるだけ安全に使える環境が整えられています。とはいえ、マクロでクリーンなコードを書くことは開発者の責任です。マクロをつくるのは、通常のElixirの関数を使うより難しいといえます。むやみにマクロを用いるのは、避けた方がよいでしょう。

Elixirには、データ構造や関数によりわかりやすく読みやすいコードを書ける仕組みがすでに備わっています。コードは黙示的より明示的に、短くよりわかりやすくすべきです。マクロはどうしても必要な場合にお使いください。

はじめてのマクロ

Elixirのマクロはdefmacro/2により定めます。本稿では、コードを基本的に.exsファイルに書いて、elixir ファイル名またはiex ファイル名のコマンドで実行しましょう。

簡単なマクロを書いて、動きを確かめてみましょう。モジュールはmacros.exsに定め、マクロと関数を加えます。コードの中身はどちらも同じです。反転した条件に応じて、引数の式を実行します。

defmodule Unless do
  def fun_unless(clause, do: expression) do
    if(!clause, do: expression)
  end

  defmacro macro_unless(clause, do: expression) do
    quote do
      if(!unquote(clause), do: unquote(expression))
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

関数は、受け取った引数をif/2に渡します。けれど、マクロが受け取るのは内部表現です(「Elixirメタプログラミング 01: 内部表現 ー quote/2とunquote/1」参照)。そして、それを差し込んだ別の内部表現を返します。

前述で定義したマクロを試すために、このモジュールでiexを開きましょう。

$ iex macros.exs
Enter fullscreen mode Exit fullscreen mode

マクロを使うには、その前にrequire/2でモジュールを要求しなければなりません。そのあと、関数と同じように呼び出せます。

iex> require Unless
Unless
iex> Unless.macro_unless(true, do: IO.puts "this should never be printed")
nil
iex> Unless.fun_unless(true, do: IO.puts "this should never be printed")
this should never be printed
nil
Enter fullscreen mode Exit fullscreen mode

マクロも関数も戻り値(nil)は同じでした。けれど、マクロはIO.puts/2に渡した文字列が出力されません。文字列が関数で出力されたのは、値を返す前に引数が評価されるからです。これに対して、マクロは渡された引数を評価しません。引数は内部表現として受け取られ、別の内部表現にされるのです。今回、定義したmacro_unlessマクロは、ifの内部表現になります。

前掲macro_unlessの呼び出しは、引数につぎのような内部表現を用いたのと同じです。

iex> Unless.macro_unless(
...>    true,
...>    [
...>      do: {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [],
...>       ["this should never be printed"]}
...>    ]
...> )
Enter fullscreen mode Exit fullscreen mode

さらに、マクロの定義も内部表現に展開すると、つぎのようになります。

{:if, [context: Unless, import: Kernel],
 [
   {:!, [context: Unless, import: Kernel], [true]},
   [
     do: {{:., [],
       [
         {:__aliases__, [alias: false, counter: -576460752303422719], [:IO]},
         :puts
       ]}, [], ["this should never be printed"]}
   ]
 ]}
Enter fullscreen mode Exit fullscreen mode

引数の内部表現は、quote/2で確かめられるでしょう。さらに、その内部表現を展開するのがMacro.expand_once/2です。

iex> expr = quote do: Unless.macro_unless(true, do: IO.puts "this should never be printed")
{{:., [], [{:__aliases__, [alias: false], [:Unless]}, :macro_unless]}, [],
 [
   true,
   [
     do: {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [],
      ["this should never be printed"]}
   ]
 ]}
iex> res  = Macro.expand_once(expr, __ENV__)
{:if, [context: Unless, import: Kernel],
 [
   {:!, [context: Unless, import: Kernel], [true]},
   [
     do: {{:., [],
       [
         {:__aliases__, [alias: false, counter: -576460752303422719], [:IO]},
         :puts
       ]}, [], ["this should never be printed"]}
   ]
 ]}
iex> IO.puts Macro.to_string(res)
if(!true) do
  IO.puts("this should never be printed")
end
:ok
iex> IO.puts Macro.to_string(expr)
Unless.macro_unless(true) do
  IO.puts("this should never be printed")
end
:ok
Enter fullscreen mode Exit fullscreen mode

Macro.expand_once/2は内部表現を受け取って、現在の環境に応じて展開します。前述の例では、マクロUnless.macro_unless/2が展開されて呼び出され、結果が返りました(__ENV__については後述します)。さらに戻り値の内部表現をIO.puts/2で文字列に出力して確かめたということです。

なお、内部表現をコードの文字列表現として確かめたいときは、つぎのように書くと簡単です。

iex> expr |> Macro.expand_once(__ENV__) |> Macro.to_string |> IO.puts
if(!true) do
  IO.puts("this should never be printed")
end
:ok
Enter fullscreen mode Exit fullscreen mode

以上が、マクロの基本的な働きです。内部表現を受け取って、別のものに変換するという役割を果たします。実際、Elixirのunless/2の実装はつぎのようなものです。

defmacro unless(clause, do: expression) do
  quote do
    if(!unquote(clause), do: unquote(expression))
  end
end
Enter fullscreen mode Exit fullscreen mode

unless/2defmacro/2def/2defprotocol/2などの構文、その他公式サイトのガイドに掲げられているコードは、純粋なElixirに加え、マクロで実装されているものも少なくありません。言語を構築している構文は、開発者がそれぞれ開発しているドメインに言語を拡張するために用いることもできます。

関数やマクロを用途に応じて定め、さらにElixirに組み込み済みの定義を上書きすることもできます。ただし、Elixirの特殊フォームだけは例外です。Elixirで実装されていないため、上書きができません。特殊フォームに何があるか、詳しくは「Kernel.SpecialForms」をご参照ください。

マクロの健全さ

Elixirのマクロは、あとで解決されます。つまり、マクロで定められた変数は、マクロが展開されるコンテキストに定義された変数と競合することはないということです。

たとえば、つぎのようにマクロと関数を、それぞれ別のモジュールに定義したとします。

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
Enter fullscreen mode Exit fullscreen mode

関数が変数に値を定義したあと、呼び出したマクロが同名の変数に異なる値を与えても、関数の変数値は変わりません。

iex> HygieneTest.go
13
Enter fullscreen mode Exit fullscreen mode

あえて、マクロが呼び出されたコンテキストに影響を与えたいときには、var!/2を使ってください。

defmodule Hygiene do
  # defmacro no_interference do
  defmacro interference do
    # quote do: a = 1
    quote do: var!(a) = 1
  end
end

defmodule HygieneTest do
  def go do
    require Hygiene
    a = 13
    # Hygiene.no_interference
    Hygiene.interference
    a
  end
end
Enter fullscreen mode Exit fullscreen mode

マクロがvar/2に与えた変数は、呼び出されたコンテキストに上書きして定義されます。

iex> HygieneTest.go
1
Enter fullscreen mode Exit fullscreen mode

上書きされたもとの変数値は使われません。そのため、コンパイル時に、それを告げる警告が示されます。

warning: variable "a" is unused
Enter fullscreen mode Exit fullscreen mode

変数のコンテキストは、内部表現の第3要素のアトムで示されます。そして、モジュールからquote/2で引用された変数は、そのモジュールをコンテキストにもつのです。そのため、他のコンテキストを汚すことなく健全さが保たれます(「健全なマクロ」参照)。

defmodule Sample do
  def quoted do
    quote do: x
  end
end
Enter fullscreen mode Exit fullscreen mode
iex> quote do: x
{:x, [], Elixir}
iex> Sample.quoted
{:x, [], Sample}
Enter fullscreen mode Exit fullscreen mode

Elixirはインポートとエイリアスにも、同じ仕組みを与えます。マクロはもとのモジュールのもとで動作し、展開された先と競合することはありません。あえて、影響を及したいときに用いるのが、var!/2alias!/1です。ただし、健全さが失われ、使われる環境を直接変えることになりますので、ご注意ください。

Macro.var/2を使うと、動的に変数をつくることができます。第1引数が変数名で、第2引数はコンテキストです。

defmodule Sample do
  defmacro initialize_to_char_count(variables) do
    Enum.map variables, fn(name) ->
      var = Macro.var(name, nil)
      length = name |> Atom.to_string |> String.length
      quote do
        unquote(var) = unquote(length)
      end
    end
  end

  def run do
    initialize_to_char_count [:red, :green, :yellow]
    [red, green, yellow]
  end
end
Enter fullscreen mode Exit fullscreen mode
iex> Sample.run
[3, 5, 6]
Enter fullscreen mode Exit fullscreen mode

環境

前述「はじめてのマクロ」の項でMacro.expand_once/2の第2引数に__ENV__を渡しました。戻り値はMacro.Env構造体のインスタンスです。構造体にはコンパイル環境の有用な情報が納められています。たとえば、現在のモジュールやファイル、行番号、現在のスコープのすべての変数などです。import/2require/2で加わったものも含まれます。

iex> __ENV__.module
nil
iex> __ENV__.file  
"iex"
iex> __ENV__.requires
[IEx.Helpers, Kernel, Kernel.Typespec]
iex> require Integer
Integer
iex> __ENV__.requires
[IEx.Helpers, Integer, Kernel, Kernel.Typespec]
Enter fullscreen mode Exit fullscreen mode

Macroモジュールの多くの関数は環境を与えて呼び出します。詳しくは、「Macro」をご参照ください。また、コンパイル環境については「Macro.Env」で解説されています。

プライベートマクロ

Elixirはdefmacrop/2で、プライベートマクロが定義されます。プライベートな関数になりますので、マクロを定義したモジュールの中で、コンパイル時にしか使えません。

defmodule Sample do
  defmacrop two, do: 2
  def four, do: two + two
end
Enter fullscreen mode Exit fullscreen mode
iex> Sample.four
4
Enter fullscreen mode Exit fullscreen mode

そして、プライベートマクロは、使う前に定義されていることが必要です。マクロは展開されてから、関数として呼び出せます。そのため、定義の前に呼び出すと、エラーが生じるのです。

defmodule Sample do
  def four, do: two + two  # ** (CompileError) macros.exs: undefined function two/0
  defmacrop two, do: 2
end
Enter fullscreen mode Exit fullscreen mode

責任のあるマクロを書く

マクロはできることが豊富な構文です。Elixirはさまざまな仕組みで、責任のあるマクロが書けるようにしています。

  • 健全: デフォルトでは、マクロ内で定義された変数は、使う側のコードに影響を与えません。さらに、マクロのコンテキストにおける関数呼び出しやエイリアスも、ユーザーコンテキストからは切り離されます。
  • レキシカル: コードやマクロをグローバルに差し込むことはできません。マクロが定められたモジュールを、明示的にrequire/2またはimport/2で使う必要があります。
  • 明示: マクロは明示的に呼び出さなければ実行できません。言語によってはパースやリフレクションなどといった仕組みも用いて、開発者が外からわからないように関数をすっかり書き替えられたりします。Elixirのマクロは、呼び出す側がコンパイルのとき明示的に実行しなければならないのです。
  • 明確: 多くの言語にはquoteunquoteに省略記法が備えられています。Elixirではフルに入力することにしました。マクロ定義と内部表現をはっきりと識別できるようにするためです。

このような仕組みはあるものの、マクロを書く責任の多くは開発者が担います。マクロの助けがいると判断した場合、マクロがAPIではないことは頭においてください。

マクロの定義は、内部表現も含めて短くしましょう。つぎのように書くのは、よくない例です。

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
Enter fullscreen mode Exit fullscreen mode

つぎのように書けば、コードは明確になり、テストや管理もしやすくなります。関数do_this_that_and_that/3は直接呼び出してテストできるからです。また、マクロに依存したくない開発者向けのAPIを設計するのにも役立つでしょう。

defmodule MyModule do
  defmacro my_macro(a, b, c) do
    quote do
      # マクロに書くのは最小限に
      # その他の処理はすべて関数に
      MyModule.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
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
gumitech
gumi TECH

Posted on December 20, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related