やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

【メタプログラミング入門】Elixirのマクロに非黒魔術士が挑む

マクロは黒魔術

以前からメタプログラミングという単語を耳にしたことはあり、存在は認知していた。マクロはメタプログラミングを行うための機能、言語という意味で捉えれば良い(マクロ in メタプログラミングという階層になっている)
要するに言語拡張のために使う言語に用意された機能というわけだ。しかし、言語の仕様を気軽に書き換える・追加してしまうことが可能なため仕様には注意を払わねばならない
拡張をしすぎた結果、誰も仕様を追えなくなってしまうということが起こり得る。これがマクロが黒魔術と呼ばれる理由である

それを理解した上で、言語拡張を行いたい場合やOSSソースコードを読もうと思う時にマクロの知識が必要になってくる(勉強会でOSSのコードを読んで撃沈した。マクロ難し杉内)

マクロに惨敗した勉強会のレポートはこちらです

www.okb-shelf.work

今回は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]}
       ]}
    ]}
 ]}

何かたくさん出てきた。しかし、よく見てみると、上記の処理に使用した演算子を所々に確認することが出来る
つまりは、このコード表現をゴチャゴチャといじってやれば処理の内容を変更できるということになる(例えば*+に変えるとかね)
そのためにはquoteunquoteの知識が不可欠になる

quoteとunquoteについて

quoteについて

先ほどチラりとquoteが何をするものか見せたが、改めて言葉で書き出すと、ElixirのコードをASTの形に変換するための構文になる じゃあ、このquoteをいつ使うのという話になるが、現状の理解で処理の通り、コード表現をASTに変換する必要がある時だ

その前にdefmacroについて触れておく。defmacrodefmodule内でのみ使用することが可能で、引数で受け取った値を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

参考文献