技術メモ

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

Elixir入門(Meta-programming編 第2章 Macros)

f:id:ysmn_deus:20190122112104p:plain

どうも、靖宗です。
今回はMacrosということで、超時空要塞ではなくちょいちょいでてたifとかquoteとかの奴です。

Foreword

大いなる力には大いなる責任が伴う、とスパイダーマンがリブートする度に回想されるベンおじさんの言葉があります。
マクロもまた同じで、コードが完結になる力には開発者への責任が伴います。
最終手段として使い、あまりおいそれと使わないほうがいいよ!と書かれています。

Our first macro

マクロの定義はdefmacro/2で行うそうです。
試しにifの反対の動作を行うunlessを定義してみましょう。
今回は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

基本的にマクロではquoted expressionが渡されます。なのでclauseunquoteしてやって値を代入する必要があります。
expressionに関しても同じです。

実際に使ってみましょう。iex macros.exsでmacros.exsを読み込んでからiexを起動します。

iex> require 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

とりあえずなんか動作はしてるっぽいです。
ただ、関数として定義した方は標準出力に表示が出て、マクロとして定義した方は何も表示されません。
これはdefmacro/2で定義してるマクロの引数はマクロを実行しただけでは評価されないからだそうです。unlessマクロでは、受け取ったquoted expressionを別のquoted expressionに変換しているだけです。
つまり、

Unless.macro_unless true, do: IO.puts "this should never be printed"

と実行した段階ではmacro_unlessというマクロの定義は

macro_unless(true, [do: {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["this should never be printed"]}])

というquoted expressionを引数にとり、

{:if, [],
 [{:!, [], [true]},
  [do: {{:., [],
     [{:__aliases__,
       [], [:IO]},
      :puts]}, [], ["this should never be printed"]}]]}

というquoted expressionを返しているに過ぎません。
このquoted expressionMacro.expand_once/2で評価することが可能です。

iex(1)> require Unless
Unless
iex(2)> 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(3)> res  = Macro.expand_once(expr, __ENV__) # Unlessモジュールのmacro_unlessが評価される
{:if, [context: Unless, import: Kernel],
 [
   {:!, [context: Unless, import: Kernel], [true]},
   [
     do: {{:., [],
       [
         {:__aliases__, [counter: -576460752303423486, alias: false], [:IO]},
         :puts
       ]}, [], ["this should never be printed"]}
   ]
 ]}
iex(4)> IO.puts Macro.to_string(res)
if(!true) do
  IO.puts("this should never be printed")
end
:ok

Macro.expand_once/2は第一引数に評価するquoted expressionを、第二引数に環境(ENV)を指定します。環境に関しては後述します。

だいたいマクロに関してはこんなものだそうです。
Unlessモジュールのmacro_unlessを例に出しましたが、実際にunless/2はElixir上で

defmacro unless(clause, do: expression) do
  quote do
    if(!unquote(clause), do: unquote(expression))
  end
end

と実装されており、先ほど書いたサンプルと同じである事が分かります。
また、defmacro/2def/2もマクロで記載されており、マクロを使いこなすと固有の領域で言語の機能を拡張することができます。(→DSL?)

Macros hygiene

quote内部で定義された変数はマクロが展開された場所の変数とは競合しないようになっているそうです。
文章だけではアレなのでサンプルを見ていきます。

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

def~~で囲われたブロックなのでスコープ的にそうなりそうな感じもしますが、今回に限ってはマクロが展開されているのでスコープ云々の話ではありません。
本来、Hygiene.no_interferenceの箇所にa=1が展開されてしまうとaの13は上書きされます。
あまり無いとは思うんですが、この変数に影響を及ぼしたい場合(今回であれば代入したい場合)は、var!を使用します。

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

一応動作しますがwarning: variable "a" is unused (if the variable is not meant to be used, prefix it with an underscore)と怒られます。

変数の干渉が起きないのは、変数それぞれにコンテキストが設定されているためです。
前の章で変数は{:x, [], Elixir}と内部では管理されていることを確認しましたが、この最後の引数のElixirの箇所でどのモジュールで利用されている変数なのかなどを管理し、2個目の引数で別のコンテキストを管理しているようです。
サンプルでは

{:x, [line: 3], nil}

となっており、行の情報も管理されているみたいなんですが、自分の環境ではでなかったので無視です。
Sampleモジュール内の変数はElixirではなくSampleというモジュール名が付与されます。

defmodule Sample do
  def quoted do
    quote do: x
  end
end

Sample.quoted #=> {:x, [line: 3], Sample}

この情報を付与する機能でimportaliasなどは実装されているようです。

動的に変数名をvar!したい場合はMacro.var/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

> Sample.run #=> [3, 5, 6]

Macro.var/2の第二引数に関してはコンテキストが入るそうですが、詳細は次の章で。

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]

Macroモジュールは基本的にこの__ENV__を利用していろんな機能を実装してるようです。
別ノードの環境データを取得してMacroを生成して云々とかするときはこの__ENV__がやりとりされるといったところでしょうか。
今のところ直接いじったりするものでもなさそうですし、頭の片隅に置いとく程度にとどめておきます。

Private macros

プライベート関数のようにプライベートマクロをdefmacropで実装かのうなようです。
コンパイル時にのみ評価されるマクロを作成したい時なんかはこれを使うんでしょうが、イマイチ使いどころが分かりません。
必要に応じて復習したいと思います。

Write macros responsibly

マクロめっちゃいいよ!って紹介。使って欲しいのか使って欲しくないのかどっちなんだ。

  • マクロは衛生的!(変数の汚染が少ない)
  • マクロは辞書的!(requireとかimportとかちゃんと書かないと使えない)
  • マクロは明示的!(きちんとした手順を踏まないとマクロは使えない)
  • マクロの言語は明確!曖昧な表現はない!

とはいえまだまだ自由にしてもいいところがあるのも事実。
協力であるが故に影響力もでかいので、マクロはコンパクトに書くことが推奨されてます。
たとえば

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
      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

APIとしての機能とマクロとしての機能ははっきりと使い分けようという意思が見て取れます。

マクロに関してはまだまだ慣れない感が拭えませんが、とりあえずガンガン使用するものではないのでライブラリの内部実装とかを見て出てくる度に復習しようと思います。