Help us understand the problem. What is going on with this article?

Elixir のマクロを読もう1

More than 1 year has passed since last update.

はじめに

gumi Inc. 2018 Advent Calendar の初日を担当することになりました gumi 幾田です. よろしくお願いします.
現在 gumi では Elixir の導入を進めておりますので, 初日は Elixir の話題で開始しようと思います.

メタプログラミングを学習する必要はあるのか?

実は, Elixir と Phoenix を用いて一般的なウェブサービスを開発するにあたり, 開発者が新たなマクロを定義する必要はない. それどころか Phoenix を使用せずに Elixir のみによって TCP などの I/F を持つ常駐プロセスのサーバを開発する際においても, 新たなマクロを定義せずに支障なく開発できる.

では, 開発者が新たなマクロを定義する理由は何だろうか? 大雑把だが次のようなものが考えられる.

  • ソースコードを短縮したい
  • ソースコードを自動生成したい

しかし, これらは前述したような開発において必須ではない. ソースコードの短縮や自動生成は, 複数名によるスキルレベルに凹凸があるチーム開発においては害悪にすらなり得る.

Phoenix の作者 Chris McCord さんも著書 Metaprogramming Elixir において「マクロを書くルール1」として「マクロを書くな」と記している.

では何故, Elixir にマクロは存在しているのだろうか?

マクロ定義を必要とする者は, 概ね次のような開発者に限られる.

  • Elixir の構文定義を行いたい Elixir 言語開発者
  • 他の言語(SQL, HTML など)を Elixir のソースコード内で自然に表現したいライブラリ開発者

Elixir では, いくつかの基本的な構文がマクロで定義されており, 標準ライブラリや一般的に普及しているライブラリも, マクロを用いて定義されているものがある. これらを開発する際, マクロの存在有無は開発効率や実現可否に大きく影響するため, Elixir にマクロが存在すること自体には意義がある.

では, 言語開発者やライブラリ開発者以外の者は, マクロを学習する必要はないのだろうか?

前述から想像できることだが, Elixir を用いた開発において, どこかの誰かが定義したマクロの使用は, 絶対に避けられない. もし貴方に, 使用が必須に近い既存マクロの定義を読む力が無い場合, 開発を行うために, そのマクロの説明文や使用例から「これは, こーいうもの」という丸暗記を行うことになる.

これは私の持論だが, 物事を効率よく的確に理解するためには, 複数の視点から対象を観察することが大切である. 例えば, 利用したい構文やライブラリがあるとして, 説明文だけではなく使用例も合わせて読む方が理解が易い. 先人から教われば, 更に理解は深まる. これに加えてソースコードを読むならば, 更に理解は深まる. 説明文が正しくとも誤読はあるだろうし, 使用例だけで全てを網羅することはできず, 側に先人はいないかもしれない. しかし, ソースコードを読む力があれば, 情報源を一つ失わずに済む. Elixir においては, 情報源を一つ確保するためにマクロを学ぶ必要がある.

メタプログラミングというと, 少し難しい印象を持つ方々もいるだろうが, 基礎を一つ一つ順番に学ぶことで, そう苦労せずに定義済みのマクロを読めるようになる.

抽象構文木 (AST: Abstract Syntax Three)

Elixir におけるメタプログラミングを学ぶ際, 一番始めに理解すべきは次の事柄である.

  • コード自体を, 基本的なデータ型で表現する方法がある
  • 基本的なデータ型は, 操作が容易である

コードを操作しやすい基本的なデータ型に変換し, それをコードから操作することが Elixir におけるメタプログラミングだと考えて差し支えない.

基本的なデータ型によるコード表現のことを, 専門用語で 抽象構文木 (AST) という. 概念の解説などは, Wikipedia 先生や Google 先生に譲るとして, これ以降の説明では「基本的なデータ型によるコード表現」のことを「AST」もしくは「コード表現 (Code Expression)」と記す.

さて, Elixir のコンパイラは, 概ね次のような手順でコードを変換して行く.

[Elixir コード] -> [Elixir AST] -> [Erlang AST] -> ..省略.. -> [Erlang バイトコード]

繰り返しとなるが, この Elixir AST を操作することがメタプログラミングである.

Elixir AST の形式

AST が基本的なデータ型で表現できることは前述した通りである. では, どのような形式だろうか? 構文木という名前が示す通り "木" の形をしている.

例えば, 次のようなコードがある.

1 + 2

これは木構造で次のように表現できる.

   +
  / \
 1   2

根に演算子, 葉にプリミティブなリテラルが入る.
なお, 演算子は "def left + right" などのように定義された二つの引数をとる関数であるため, この例では「関数 +/2 に引数として数値 1 と 2 を渡した」と解釈できる.

この木構造は Elixir の基本的なデータ型で次のように表現できる.

{:+, [], [1, 2]}

Elixir の AST は三要素のタプルで構成されている.

要素位置 意味 形式
第一要素 関数名 アトム
第二要素 メタデータ キーワードリスト
第三要素 引数 リスト

各要素の細かい説明は後述する.

ここで iex を起動し, 次のコードを実行して結果を確認してほしい.

iex> code_expression = {:+, [], [1, 2]}
{:+, [], [1, 2]}

iex> Macro.to_string(code_expression)
"1 + 2"

iex> Code.eval_quoted(code_expression)
{3, []}

Macro.to_string/1 は, コード表現を文字列に変換でき, Code.eval_quoted/1 は, コード表現を評価できるため, 確認に使用できる.

もう少し複雑な例を挙げる.

1 + 2 * 3

これは木構造で次のように表現できる.

   +
  / \
 1   *
    / \
   2   3

ご覧の通り木が深くなる.

この木構造をコード表現にすると次のようになる.

{:+, [], [
  1,         # +/2 の第一引数
  {:*, [], [ # +/2 の第二引数として */2 の結果を渡す
   2,        # */2 の第一引数
   3         # */2 の第二引数
  ]}
]}

引数 2 と 3 を関数 */2 に与え, その結果と 1 を関数 +/2 に与えている.

iex を起動し, 確認する.

iex> Macro.to_string({:+, [], [1, {:*, [], [2, 3]}]})
"1 + 2 * 3"

念のため, 一例だけ, 演算子ではない関数の場合を示す.

次のようなコードがあったとする.

max(20, div(100, 2))

木構造は次のとおり.

  max
 /   \
20   div
    /   \
  100      2

コード表現は次のとおり.

{:max, [], [20, {:div, [], [100, 2]}]}

iex を起動し, 確認する.

iex> Macro.to_string({:max, [], [20, {:div, [], [100, 2]}]})
"max(20, div(100, 2))"

quote/2

ここまで, 木構造のイメージを掴むため,「コード -> 木構造 -> コード表現」と手動で変換してきたが, Elixir にはコードを簡単にコード表現に変換できるマクロ quote/2 が存在する.

iex を起動し, 次のコードを実行して結果を確認してほしい.

iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}

iex> quote do: div(2, 1)
{:div, [context: Elixir, import: Kernel], [2, 1]}

iex> quote do: 1 + 2 * 3
{:+, [context: Elixir, import: Kernel],
 [1, {:*, [context: Elixir, import: Kernel], [2, 3]}]}

前述において「コードを操作しやすい基本的なデータ型に変換し, それをコードから操作することが Elixir におけるメタプログラミング」だと述べたが, ここまでで, 「コードを操作しやすい基本的なデータ型に変換」の解説は終わりである. 続けて, データ型を操作する解説を行っていく.

unquote/1

quote/2 と対をなす, unquote/1 が存在する. unquote/1 は, コード表現をコードに変換する.

iex を起動し, 次のコードを実行して結果を確認してほしい.

iex> unquote {:+, [], [1, 2]}
** (CompileError) iex:9: unquote called outside quote

"1 + 2" を戻り値として期待したが, unquote/1 は quote/2 のスコープ内でのみ使用可能であるためエラーとなる. では, quote/2 のスコープ内で unquote/1 を使用してみよう.

iex を起動し, 次のコードを実行して結果を確認してほしい.

iex> quote do: unquote {:+, [], [1, 2]}
{:+, [], [1, 2]}

動作を観測し難いが, コード表現 -> コード -> コード表現 という変換が行われている.
では, この unquote/1 は何に使用するのだろうか? コード表現に, 別のコード表現を埋め込む際に使用できる.

iex を起動し, 次のコードを実行して結果を確認してほしい.

iex> code_expression = 1

iex> quote do: code_expression + 2
{:+, [context: Elixir, import: Kernel], [{:code_expression, [], Elixir}, 2]}

iex> quote do: unquote(code_expression) + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}

quote/2 内で直接 code_expression を使用すると, 変数アクセスとして解釈されてしまう. そこで unquote/1 を用いて code_expression をコードに変換している.
他のマクロ入門記事では, unquote/1 は変数を埋め込むために使用するような解説がされているが, 本来的には, unquote/1 はコードを埋め込むために使うものである.
前述の例であれば, 1 というリテラルがコード表現として完結しているため, 変数を埋め込んでいるように見える.

iex を起動し, 次のコードを実行して結果を確認してほしい.

iex> code_expression = quote do: 1 + 2
iex> quote do: unquote(code_expression) + 3
{:+, [context: Elixir, import: Kernel],
 [{:+, [context: Elixir, import: Kernel], [1, 2]}, 3]}

このように, unquote/1 を用いると, 小さなコード表現を組み合わせて, 大きなコード表現を組み立てることができる.

defmacro/2

Elixir では, defmacro/2 を用いてマクロを定義できる. defmacro/2 で定義されたマクロは, コード表現を受け取るため, コード表現を返すようにする.

iex を起動し, 次のコードを実行して結果を確認してほしい.
なお, defmacro/2 は, defmodule/2 のスコープ内でのみ使用可能である.

iex> defmodule OurMacro do
...>   defmacro plus_to_minus({:+, meta, args}) do
...>       {:-, meta, args}
...>   end
...> end

iex> require OurMacro

iex> OurMacro.plus_to_minus 2 + 1
1

+/2 を -/2 に変換して評価するという無意味な動作を行うマクロだが, defmacro で定義されたマクロが「コード表現を受け取り, コード表現を返す」という動作は理解しやすいのではなかろうか.

もう少し意味のあるマクロを, quote/2 と unquote/1 を使用して定義する.

iex を起動し, 次のコードを実行して結果を確認してほしい.

defmodule OurMacro do
  defmacro unless(expr, do: block) do
    quote do
      if !unquote(expr), do: unquote(block)
    end
  end
end

iex> require OurMacro
iex> OurMacro.unless false, do: IO.puts "hello"
hello
:ok
iex> OurMacro.unless true, do: IO.puts "hello"
nil

引数として渡されたコード表現を unquote/1 でコードに変換し, quote/2 内の if/2 の引数として引き渡している. また, quote/2 の戻り値はコード表現であるため, そのまま, マクロ unless/2 の戻り値として使用している.

おわりに

ここまでで, Elixir のマクロを読むための基礎情報の解説は終わりとなります. いかがでしたでしょうか? もし私の解説で分かりにくいところがあれば, 是非, ご指摘やご質問をコメント欄にいただければと思います. Elixir 初学者が一人でもマクロにつまずかないように学習をサポートできれば幸いです.

ちなみに, 次回の「Elixir のマクロを読もう2」では, 今回紹介しなかったマクロ定義の道具を解説し, 「Elixir のマクロを読もう3」では, 言語コアで定義されているマクロを幾つかコールドリーディングしてみる予定です.

追記

記事を書いている最中に, 基礎情報の範囲外として除外した内容を後述します. この内容を理解していただかなくとも, これより上の解説で十分に定義済みのマクロを読む準備はできている想定ですが, さらに深くマクロを理解できる助けになれば幸いです.

コード表現の第一要素 (関数名)

本記事において, コード表現の第一要素は, 関数名を示しており, アトム形式で入ると説明したが, 実際はアトムだけで関数名は表現できない.

次のようなコードがあったとする.

hd(String.split("Hello World", " "))

木構造で次のように表現できる.

        hd
       /
      String.split
      /            \
"Hello World"     " "

ところが, コード表現は次のとおり.

{:hd,
 [],
 [
   {{:., [], [{:__aliases__, [], [:String]}, :split]}, [],
    ["Hello World", " "]}
 ]}

"String.split" と入るべきところに, 次のコード表現が入っている.

{:., [], [{:__aliases__, [], [:String]}, :split]}

この辺りは, Kernel.expand_aliases/2 の定義を読むと Module.concat/2 しているだけだと分かる.

コード表現の第二要素 (メタ情報)

本記事において, コード表現に含まれているメタ情報を無視してきたが, 何故, 無視していたか説明する.

コード表現に含まれているメタ情報は, Elixir コンパイラが AST を展開(defmacro を適用)したり Erlang AST に変換する際に使用するものであり, 私たちがマクロを読み解く際には全く必要のない情報である.

とはいえ, ここで終わると面白くないため, 少し実験をしてみる.

次のようなコードを quote/2 でコード表現に変換すると, メタ情報が付属する.

iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}

これは, +/2 が Kernel モジュールに属していることを示している.

iex 起動時に, Kernel モジュールに属する +/2 が読み込まれていることを確認するには, 次のとおり.

iex> __ENV__.functions[Kernel] |> Enum.filter(fn {:+, 2} -> true; {_n, _a} -> false end)
[+: 2]

ここで, +/2 の読み込みを止めてみよう.

iex> import Kernel, except: [+: 2]
Kernel

iex> __ENV__.functions[Kernel] |> Enum.filter(fn {:+, 2} -> true; {_n, _a} -> false end)
[]

当然, +/2 が使えなくなってしまう.

iex> 1 + 2
** (CompileError) iex:NN: undefined function +/2

この状態でコード表現を取得すると…

iex(20)> quote do: 1 + 2
{:+, [], [1, 2]}

quote/2 マクロが +/2 関数を発見できず, メタ情報が抜け落ちる.

コード表現の第三要素 (引数)

本記事内で取り上げなかった, 木構造の葉となるリテラルについて補足する.

アトム :atom
数値 1
小数 1.0
リスト [1, 2, 3]
文字列 "string"
二要素のタプル {key, value}

葉は, 単体としてコード表現として成立する.

iex> quote do: 1
1
iex> quote do: :test
:test

タプルは二要素のみが葉となる. 一要素や三要素以上は, 葉とならない.

iex> quote do: {1}
{:{}, [], [1]}

iex> quote do: {1, 2}
{1, 2}

iex> quote do: {1, 2, 3}
{:{}, [], [1, 2, 3]}
Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away