elixirはプログラマの万能薬になるか その4

今回はマクロプログラミングをelixir自身を例題にしていこうと思うが、マクロをプリプロセッサと勘違いしている人も多いので、まずは入門から。

マクロ入門

Elixirのマクロは、コンパイルプロセス中において、構文解析後のツリーを入力として、別のツリーを返すフックとして機能する。そして、そのフックはElixir自身を用いて記述することができる。この記述のしやすさと、ElixirのクロージャサポートによりLispなみの拡張性を持っている*1

フェーズ 処理内容
(プリプロセッサ) (文字列-->文字列)
1 字句解析 文字列-->トークン列
2 構文解析 トークン列-->タプルによる構文ツリー
3 マクロ 構文ツリー-->構文ツリー
4 コード生成 構文ツリー-->beamバイナリ生成

ということで、詳細は前回のエントリを参照していただきたい。

マクロプログラミングの実際

elixirではデリゲーションを行うためにdefdelegateが提供されている。それとよく似たマクロ delegate [{name, arity}|t], do: target を考えてみよう。名前以外はdefdelegateと同じ機能を持ち、あるモジュールの関数群を他のモジュールに委譲したい場合に使う事を目的としている。
Erlang標準のリストモジュールを元にMyListを作ろうとしているとしよう。reverseやmemberはもとと同じにしたいので、

defmodule MyList do
 delegate [reverse: 1, member: 2], to: Erlang.lists
end

のように書いておきたい。そしてこれをこう展開したい。

defmodule MyList do
 def reverse(arg1) do
  apply Erlang.lists, reverse, [arg1]
 end
 def member(arg1, arg2) do
  apply Erlang.lists, member, [arg1, arg2]
 end
end

ではこれを実装するマクロdelegateを考えていこう。
MyMacroモジュールに実装するとして、コアはこんな感じとなるだろう。

defmodule MyMacro
 defmacro delegate [{fname, arity}|t], to: func
   args = makeargs(arity)
   quote do
     def unquote(fname).(unquote_splicing(args)) do
       apply unquote(func), unquote(fname), [unquote_splicing(args)]
     end
   end
 end
end

makeargs/1は指定された数だけの仮引数として使用できるアトムリストを返す関数であり、例えばこんな感じで実装できる。

def makeargs_1(arity) do
 Erlang.lists.map(fn(x) -> 
   list_to_atom(List.flatten(Erlang.io_lib.format("arg~p", [x]))) end,
   Erlang.lists.seq(1,arity))
end

このmakeargs_1を実行してみると、

iex> MyMacro.makeargs_1(2)
[:"arg1",:"arg2"]
iex> 

いい感じで動いていそうだ。これをquoteされたリストにどう変換するか考える。まず、通常の関数の引数がどのようにquoteされているかを調べてみる。

iex> quote do    
...> defmodule M do
...>  def func(arg1, arg2, arg3) do
...>   true
...>  end
...> end
...> end
{:defmodule,0,[{:__ref__,0,[:M]},[{:do,{:def,0,[{:func,0,[{:"arg1",0,:quoted},{:"arg2",0,:quoted},{:"arg3",0,:quoted}]},[{:do,true}]]}}]]}
iex> 

つまり、{引数アトム, 0, :quoted} というタプルのリストになっている。このことから、makeargs/1は、makeargs_1/1を使い、

def makeargs(arity) do
 arglist = makeargs_1(arity)
 Erlang.lists.map(fn(x) -> {x, 0, :quoted} end, arglist)
end

と書けそうだ。makeargs_1自体もmapを使っているため、fnを置き換えて、

def makeargs(arity) do
 Erlang.lists.map(fn(x) -> 
     arg = list_to_atom(List.flatten(Erlang.io_lib.format("arg~p", [x])))
     {arg, 0, :quoted}
   end,
   Erlang.lists.seq(1,arity))

となる。makeargs/1を実行してみると、うまく動いていることが分かる。

iex> MyMacro.makeargs(3)
[{:"arg1",0,:quoted},{:"arg2",0,:quoted},{:"arg3",0,:quoted}]
iex> 

次にdelegate本体だが、上のコアだけの定義だと最初のfnameタプルのみが展開される形になるため、残りを再帰で実装する感じで書いてみる。

defmacro delegate [{fname, arity}|t], to: func do
   args = makeargs(arity)
   e = quote do
     def unquote(fname).(unquote_splicing(args)) do
       apply unquote(func), unquote(fname), [unquote_splicing(args)]
     end
   end
   case t do
   match: []
    [e]
   else:
    [e | delegate(t, to: func)]
   end
 end

最初の一要素分をquoteしたあとは、残りを再帰的に呼び出して変換していく。unquote_splicing/1は、リストの中身だけをその場に展開することをのぞけばunquote/1と同じである事に注意すると、素直な実装だが、よく見てみると、{fname,arity}リストを一対一で quote do: def fname リストに変換する形がハッキリと見えてきくる。Erlang.lists.mapで作り直してみる。

defmacro delegate2(tuplelist, to: func) when is_list(tuplelist) do
  Erlang.lists.map(fn({fname, arity}) ->
    args = makeargs(arity)
    quote do
     def unquote(fname).(unquote_splicing(args)) do
       apply unquote(func), unquote(fname), unquote(args)
     end
    end
  end, tuplelist)
end

elixir標準のEnum.map/2を使うともっと簡単に書けて、

defmacro delegate3(tuplelist, to: func) when is_list(tuplelist) do
  Enum.map tuplelist, fn({fname, arity}) ->
    args = makeargs(arity)
    quote do
     def unquote(fname).(unquote_splicing(args)) do
       apply unquote(func), unquote(fname), unquote(args)
     end
    end
  end
end

となる。
このようにElixir自身がElixir自身で拡張していけるようになっている。この機能を使って、ExUnit(ユニットテストフレームワーク)やレコードやプロトコル機能、果てはif文すらもマクロとして実装されている。
Paul GrahamはOn LispLispは拡張可能なプログラミング言語であると書いているが、Elixirもその能力を持っている事がわかっていただけるだろう。
次回は、レコード機能やプロトコル機能について説明予定。

On Lisp

On Lisp

*1:リードマクロがあれば、構文解析前にもフックを入れることができるようになるのだが