マクロは黒魔術
以前からメタプログラミングという単語を耳にしたことはあり、存在は認知していた。マクロはメタプログラミングを行うための機能、言語という意味で捉えれば良い(マクロ in メタプログラミングという階層になっている)
要するに言語拡張のために使う言語に用意された機能というわけだ。しかし、言語の仕様を気軽に書き換える・追加してしまうことが可能なため仕様には注意を払わねばならない
拡張をしすぎた結果、誰も仕様を追えなくなってしまうということが起こり得る。これがマクロが黒魔術と呼ばれる理由である
それを理解した上で、言語拡張を行いたい場合やOSSのソースコードを読もうと思う時にマクロの知識が必要になってくる(勉強会でOSSのコードを読んで撃沈した。マクロ難し杉内)
マクロに惨敗した勉強会のレポートはこちらです
今回はgumiの幾田さんのqiita記事を参考にしながら、プログラミングElixirのマクロの章(20章)を読み進めていく形にした。要は情報を組み合わせただけですが、惨敗した勉強会の当日の自分でも分かるようにまとめたつもりだ
プログラミングElixir読んだけど、Elixir Schoolのメタプログラミング読んだけど、よく分からなかった方を自身含めて少しでも救済できればと思います
マクロの仕組み
Elixirはマクロを用いてAST(抽象構文木)というデータ構造をいじるという操作を行う。ASTという単語をコンピューターサイエンスを集中的に勉強した時にお目にかかったことがある。一言でいえば、ASTというのは記述したコードをパースして生成される処理をまとめたデータ構造だ
ElixirにおけるASTは3つの要素をもつタプルとして表現されている
{関数名, メタデータ, 引数} -> {atom, keyword list, list}
先出しとなるが、quote
を使うことでASTとなったコード表現を確認することが出来る。ためしにIO.puts("nice")
のASTを確認してみよう
確かに3つの要素で構成されたタプルとなっている
iex(2)> quote do IO.puts("nice") end {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["nice"]}
もう少しだけ、複雑な処理のASTを確認してみる。Enum.mapで10までの数値を倍にしてみる
iex(3)> quote do Enum.map(1..10, fn num -> num * 2 end) end {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :map]}, [], [ {:.., [context: Elixir, import: Kernel], [1, 10]}, {:fn, [], [ {:->, [], [ [{:num, [], Elixir}], {:*, [context: Elixir, import: Kernel], [{:num, [], Elixir}, 2]} ]} ]} ]}
何かたくさん出てきた。しかし、よく見てみると、上記の処理に使用した演算子を所々に確認することが出来る
つまりは、このコード表現をゴチャゴチャといじってやれば処理の内容を変更できるということになる(例えば*
を+
に変えるとかね)
そのためにはquote
とunquote
の知識が不可欠になる
quoteとunquoteについて
quoteについて
先ほどチラりとquote
が何をするものか見せたが、改めて言葉で書き出すと、ElixirのコードをASTの形に変換するための構文になる
じゃあ、このquote
をいつ使うのという話になるが、現状の理解で処理の通り、コード表現をASTに変換する必要がある時だ
その前にdefmacro
について触れておく。defmacro
はdefmodule
内でのみ使用することが可能で、引数で受け取った値をASTの形式として解釈をして内部のASTをElixirでの実行結果として返す。以下の処理の違いをみれば、どんな値がreturnされているのかが目に見て分かる
まずは受け取っている値から確認する
iex(8)> defmodule Sample do ...(8)> defmacro arg_val(val) do ...(8)> IO.inspect(val) ...(8)> quote do ...(8)> unquote(val) ...(8)> end ...(8)> end ...(8)> end iex(9)> require Sample Sample : : iex(12)> Sample.arg_val(%{"key" => "nice"}) {:%{}, [line: 12], [{"key", "nice"}]} %{"key" => "nice"}
次にどんな値を返すのか見てみよう
iex(1)> defmodule Sample do ...(1)> defmacro in_quote() do ...(1)> quote do ...(1)> Enum.map(1..10, fn num -> num * 2 end) ...(1)> end ...(1)> end ...(1)> defmacro not_in_quote() do ...(1)> Enum.map(1..10, fn num -> num * 2 end) ...(1)> end ...(1)> def normal() do ...(1)> quote do ...(1)> Enum.map(1..10, fn num -> num * 2 end) ...(1)> end ...(1)> end ...(1)> end {:module, Sample, <<70, 79, 82, 49, 0, 0, 6, 156, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 201, 0, 0, 0, 18, 13, 69, 108, 105, 120, 105, 114, 46, 83, 97, 109, 112, 108, 101, 8, 95, 95, 105, 110, 102, 111, 95, 95, ...>>, {:not_in_quote, 0}} # macroを使用するためにrequireを使う iex(2)> require Sample Sample
defmacro
内でquote
を使用した場合
iex(3)> Sample.in_quote() [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
defmacro
内でquote
を使用していない場合
iex(4)> Sample.not_in_quote [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
def
内でquote
を使用した場合
iex(5)> Sample.normal {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :map]}, [], [ {:.., [context: Sample, import: Kernel], [1, 10]}, {:fn, [], [ {:->, [], [ [{:num, [], Sample}], {:*, [context: Sample, import: Kernel], [{:num, [], Sample}, 2]} ]} ]} ]}
この場合にだけ、ASTの状態で値がreturnされた。なるほど... 何となく違いが分かってきたような気がする
unquoteについて
先ほど使用したquoteのブロック内部に変数を使用しようとすると以下のような結果になってしまう
iex(1)> word = "nice" "nice" iex(2)> quote do: word {:word, [], Elixir}
ありゃ、変数がquote
のブロック内部では上手く値が読み込まれない模様。どうすれば...
こんな場合に使用するのがunquote
の構文だ
iex(3)> quote do: unquote(word) "nice"
こんな感じでquote
との組み合わせでそれっぽいことが出来た
つまりは用途としてはマクロ作成時にElixirのsyntaxを使う中で変数を扱いたい時(正しくはコードを差し込みたい)にquote
と組み合わせて使えば良い
iex(81)> quote do: IO.puts(unquote(word)) {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["nice"]}
黒魔術(マクロ)入門
プログラミングElixirを参考にmyifの作成をしてみよう。基礎知識を身につけたはずなので後はコードを理解するだけ
do: __, else: __
の値を渡り方と取得については以下を参考にしてほしい
iex(17)> tmp = fn val -> Keyword.get(val, :do, nil) end iex(18)> tmp.(do: "nice", else: "boat") "nice"
さっそくマクロを書いてみる(マルパクリ)
defmodule MyMacro do # 全ての引数がASTとして受けられる defmacro myif(condition, clauses) do do_cal = Keyword.get(clauses, :do, nil) else_cal = Keyword.get(clauses, :else, nil) quote do case unquote(condition) do # 一時変数を用意してガード節で評価(valはunquoteすることなく文字列として扱い実行時には変数名になる) val when val in [false, nil] -> unquote(else_cal) # false時の処理 _ -> unquote(do_cal) #true時の処理 end end end end defmodule Main do require MyMacro def main() do MyMacro.myif(1==2, do: IO.puts("nice"), else: IO.puts("boat")) end end
では、実行してみる
iex()> Main.main() boat
おお、いいですね。それっぽいことが出来た。ちなみに今更だが、defmacro
の引数として受け取っている値は受け取り時に評価されずにマクロの実行時に評価される。そのためIO.puts("nice")
とIO.puts("boat")
の値が受け取り時に評価されていない(標準出力されていない)ということになる
普通に引数にIO.puts()
何かを渡すと以下のように動作してしまう
iex(20)> normal = fn _, exec -> Keyword.get(exec, :do, nil) end #Function<12.128620087/2 in :erl_eval.expr/5> iex(21)> normal.(1==2, do: IO.puts("nice"), else: IO.puts("boat")) |> IO.inspect() nice boat :ok :ok
プログラミングElixirの練習問題に挑む
程よいレベル感の問題が付属されていたので、チャレンジしてみた。関数として扱いたい場合はアトムにするという点で若干詰まった
20分程で理解出来たが、こんなことが出来ていいの?という驚きは隠せない。マクロは用法用量を守って正しく使うべきだ
defmodule Times do defmacro times_n(num) do str_num = Integer.to_string(num) func_name = :"times_#{str_num}" quote do def unquote(func_name)(val) do unquote(num) * val end end end end defmodule Test do require Times Times.times_n(3) Times.times_n(10) end
実行結果
iex(25)> Test.times_ times_10/1 times_3/1 iex(25)> Test.times_3(3) 9 iex(26)> Test.times_10(10) 100
参考文献
- プログラミングElixir
- Elixir のマクロを読もう1
- Macros
- Elixir School メタプログラミング
- Elixir: Macro入門