【第14回清流elixir勉強会】Elixirのマクロを完全に理解する会

トピック

f:id:takamizawa46:20191116231318p:plain
elixir-sr.connpass.com

第14回目となる清流elixirの勉強会を開催しました
今回からは本格的にリモート参加枠を用意した。しかし、これが思っていたよりも何倍も難しい。会場のホットな温度感や議論が始まるとリモート参加者にはその現場感を伝えることが出来ない。トライアンドエラーでよりリモート参加者の方に価値ある時間が提供出来るように創意工夫していきたい

また今回は普段より非常にお世話になっている、福岡を拠点に広く活動されているfukuoka.exさんと合同での勉強会を開催しました
今回で合同の勉強会は2回目となりました。いつも知識を共有して下さり、感謝しかありません...

fukuokaex.connpass.com

最近は普段使用している会議室がどこかしらの団体とバッティングしているようで、本当に隔週開催のペースで活動したいものの、中々、上手くいっていない 。金曜日に固定で19:30~使える会場を抑えたいが、何かに依存したくないな〜とも思いつつ、頭を悩ませている

それでも清流elixirの名前が着実に広まってきており、多くの方からお声掛けを頂いている。本当にありがたい限りです
弱い人でも勉強会は開催できる、参加できるってことを広く伝えていきたい。やるかやらないかだけの問題だと思うので...

というか、コミュニテイ人数が一気に増えてて笑うしかない(圧倒的僥倖...!
そろそろ一度は地元、岐阜でElixirの勉強会をやってもいいのかなと思っている

清流elixir-infomation
開催場所: 丸の内(愛知)
参加人数: 8 -> 12 update!!
コミュニティ参加人数 : 21 -> 36 update!
2019/11/16現在

第14回の勉強会について

今回は第13回の勉強会で涙を流すことになったElixirのマクロについて完全に理解するために、この勉強会を開催した
書籍プログラミングElixirの第20章をベースにElixirのマクロについて理解を進めていくことにした。全体の流れは私が以前、予習的にまとめた以下の記事を参照して頂ければと思う

www.okb-shelf.work

当日も同じように

  • ElixirにおけるASTについて
  • quoteとunquoteについて
  • defmacroについて
  • macroを使ってmyifを作る

という流れで理解を進めた。しかしながら、人に解説をすることで自分の理解していない粗の部分が分かった。今回は勉強会を通して自身が深く理解をした点について列挙していこうと思う

評価される順番がマクロは異なる

以前からマクロは遅延評価でっせという話は聞いていたし、何となく理解していた。なので引数からIO.puts("nice")のような値を渡しても、その瞬間にniceが標準出力されることはない
即時に評価されるのであれば、以下の処理がnicekeytrueでもfalseでも出力されるはず

iex(1)> defmodule Sample do
...(1)>   defmacro exec(arg1, key) do
...(1)>     quote do
...(1)>       if unquote(key) do
...(1)>         unquote(arg1)
...(1)>       end
...(1)>     end
...(1)>   end
...(1)> end
{:module, Sample,
 <<70, 79, 82, 49, 0, 0, 4, 172, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 137,
   0, 0, 0, 15, 13, 69, 108, 105, 120, 105, 114, 46, 83, 97, 109, 112, 108, 101,
   8, 95, 95, 105, 110, 102, 111, 95, 95, ...>>, {:exec, 2}}

iex(2)> require Sample
Sample

key=trueの時

iex(3)> Sample.exec(IO.puts("nice"), true)
nice
:ok

key=falseの時

iex(4)> Sample.exec(IO.puts("nice"), false)
nil

この結果がマクロは即時評価ではなく、遅延評価であることを表している。これが通常の関数定義に用いるdefであれば、以下のように出力されるからだ

iex(1)> defmodule Sample do
...(1)>   def exec(arg1, key) do
...(1)>     if key do
...(1)>       arg1
...(1)>     end
...(1)>   end
...(1)> end
{:module, Sample,
 <<70, 79, 82, 49, 0, 0, 4, 208, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 135,
   0, 0, 0, 15, 13, 69, 108, 105, 120, 105, 114, 46, 83, 97, 109, 112, 108, 101,
   8, 95, 95, 105, 110, 102, 111, 95, 95, ...>>, {:exec, 2}}
iex(2)> Sample.exec(IO.puts("nice"), true)
nice
:ok
iex(3)> Sample.exec(IO.puts("nice"), false)
nice
nil

あ、完全に理解した(何も分かってない

さらに評価される順序は異なる

マクロが遅延評価であることは分かったが、さらにマクロは細かく評価される時系列が異なるようだ。それを表しているのが以下のマクロ記述。これは書籍プログラミングElixirの第20章から引っ張ってきたものだ

defmodule MyMacro do
  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 when val in [false, nil] -> unquote(else_cal)
        _ -> unquote(do_cal)
      end
    end
  end
end

そもそも、気になっていた事があって、なんでdo_calelse_calquote doのブロック外にいるのかがよく理解出来ていなかった。quote doの内部で変数が扱いたいのならunquoteを使えばいいじゃん〜って思うわけだが...

これにはマクロの中(defmacro)でもさらに評価される順番が異なるのではないかという仮説と結果によって、なぜquote doの外に記述しているのかを理解することが出来た
以下の処理の結果を見てみると非常に興味深い。なぜか、niceは一度のみ出力されている

defmodule MyMacro do
  defmacro myif(condition, clauses) do
    do_cal = Keyword.get(clauses, :do, nil)
    else_cal = Keyword.get(clauses, :else, nil)
    IO.puts("nice")
    
    quote do
      case unquote(condition) do
        val when val in [false, nil] -> unquote(else_cal)
        _ -> unquote(do_cal)
      end
    end
  end
end

defmodule Foo do
  require MyMacro
  def main() do
    MyMacro.myif(1==2, do: IO.puts("nice"), else: IO.puts("boat"))
  end
end

Foo.main()
Foo.main()

# nice
# boat
# boat

何故なんだ...しかし、この現象はquote doの外部(上部)に記述されているコードがquote doの部分が評価される前に一度だけ、評価されていると考えると自然な結果だと理解することが出来る

一応、偶然かもしれないのでFoo.main()をめっちゃ呼び出しておく

Foo.main()
Foo.main()
Foo.main()
Foo.main()
Foo.main()
Foo.main()

# nice
# boat
# boat
# boat
# boat
# boat
# boat

つまりdefmacroの内部でも評価される順番は存在しており、現状の知識理解だと

  1. quote doの外部
  2. qiote doの内部

という順になっており、同じdefmacroの内部であっても時系列が異なることが確認出来た。userという構文や__before_compile__のような指定がマクロに指定できる事は、同じマクロであっても評価される時系列が異なるためと考えると存在している理由が分かる

またdo_cなどをquote doの外部に記述することで、 何が嬉しいかというと

  • unquote()をめっちゃ使う必要がなくなる(Keyword.get()の戻り値を何度も使用しようとすると、codeが長くなって可読性が落ちる)
  • def :nice() doのような関数定義をことを行うことがquote do内部で出来ないため、quote doの外部で宣言しておく必要がある
defmodule Sample do
  defmacro hoge(func_name) do
    to_atom = :"func_name"

    quote do
      def unquote(to_atom)(num1, num2) do
        num1 * num2
      end
    end
  end
end
  • quote doの内部にあるものは、マクロ実行時に都度、評価されるためパフォーマンスが悪くなるのではないかと考えられる(複雑な計算処理は事前に行なっておくなど)

後日談とマクロは健全

これは参加者の方から頂いた受け売りの知識だが、C言語の場合にはマクロはスコープが切られておらず、副作用を及ぼす可能性が高い(同名の変数を使用してしまうとスコープ内となるため、簡単に上書きが出来てしまう)
しかしElixirの場合はマクロがdefmacroquote doの内部に独自のスコープを持っており、お互いに影響を及ぼすことが無く、影響範囲さえ理解していれば安全に使うことが出来るようだ

闇の深淵を覗いてしまった...

今回の勉強会を通じて、自身のプログラミングの評価順序?のような概念がぶっ壊れた
pythonやjsぐらいしかまともにやってきたことがないので、コードは書かれている順に評価されていくものだと思い込んでいた

しかし、ながらElixirのマクロでは部分的に先に評価されたり、マクロの中でも評価される順序があったりと、頭がおかしくなりそうだ
非常に勉強になった。もう何も怖くない

参考文献

  • プログラミングElixir