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

Elixirで関数の引数にunquoteを指定する構文の用途 (def func1(unquote(p))って何をしてる?)

More than 3 years have passed since last update.

Elixirの各種パッケージ内に、時々下記のようなコードが登場します。

defmodule Module1 do
  〜省略〜
  def hoge(unquote(p)) do
   〜省略〜
  end
end

unquoteに関してはこちらこちらで説明されていますが、関数の引数をunquoteする構文に関しては説明されていません。

ということでこの用法に関して調査してみました。

サンプル1

本題に入る前に、まず下記のコードを見て頂ければと思います。

defmodule Module1 do
  a = "hoge"
  b = "moge"
end

このモジュールのa = "hoge"b = "moge"というコードは、いつ実行されるのでしょうか?

答えは「コンパイル時」です。コンパイル後には上記のコードは消えてしまいます。

逆に言うと、「モジュール内ではコンパイル時にコードを実行できる」ということになります。

サンプル2

次に、下記のコードを見て頂ければと思います。

defmodule Module1 do
  p = "hoge"
  def func1 do
    unquote(p)
  end
end

Module1.func1
# => "hoge"

上記のコードは問題なく実行できます。unquotequoteの内部でしか使えないというのがルールですが、defmoduledefも実際にはマクロなので、上記のfunc1のdoブロックはdefマクロ内のquote式内で展開されるため、この構文はエラーになりません。

イメージとしては下記みたいなものだと考えて良いのではないかと思います。

defmodule Module1 do
  p = "hoge"
  quote do
    unquote(p)
  end
end

ご興味のある方はElixirのKernelモジュール内でdefが定義されている箇所をご参照頂ければと思います。

サンプル3

ここからが本題です。下記のコードを見て頂ければと思います。

defmodule Module1 do
  p = "hoge"
  def func1(unquote(p)) do
    "func1 called"
  end
end

Module1.func1("foo")
# => ** (FunctionClauseError) no function clause matching in Module1.func1/1

コンパイルは正常に完了し、Module1.fun1/1という関数も確かに定義されていますが、Module1.func1("foo")のように実行してみると、FunctionClauseErrorが発生します。

つまり、Module1.fun1/1という関数は存在するけど、引数がマッチしなかったよというエラーが発生しているということです。下記の呼び出しも全てFunctionClauseErrorになります。

Module1.func1(100)
Module1.func1({100})
Module1.func1([a: 100])
Module1.func1(%{a: 100})

どのような引数を渡すとこの関数を実行することが出来るのでしょうか?

答えは、下記のようになります。

Module1.func1("hoge")

要するに、この関数の引数としてパターンマッチが成功するのは"hoge"だけだということになります。

つまり、Module1.func1/1という関数は、下記のように定義されているということです。

defmodule Module1 do
  def func1("hoge") do
    "func1 called"
  end
end

引数として、変数ではなく文字列がそのまま指定されていますので、この文字列とパターンマッチする引数を渡さないと、すべてFunctionClauseErrorになる、ということになります。

つまり、関数の引数にunquoteを指定する構文は、要するにパターンマッチなわけですが、その中でも最も制限が厳しい用法、

実行時に渡される引数の「型」ではなく「値」を制限する

という目的で使用されている、ということになります。

考察

上記のような構文は、PhoenixやEctoの内部で散見されますが、用途としてはおそらく「引数チェックの省略」「不具合の早期発見」「安全性の確保」等のためだと思われます。

マクロ内で定義された関数に渡される引数が、「型」や「キー名」等だけでなく、「値」も含めてコンパイル時の定義と完全に一致することが必要になり、一致しない場合はFunctionClauseErrorをraiseしてくれますので、関数内での引数チェックが不要になりますし、不具合を早期に発見することが可能になる、そういう目的で使用されているのではないかと思われます。

注意

上記のサンプルコードでは、引数をunquote(p)のように直接unquoteに渡していましたが、この場合pは「クォート式に変換されている」必要があります。

上記のサンプルでは、pはクォート式に変換されていませんが、これはpに設定している値が文字列のため、クォート式に変換しても内部表現が変わらないのでこの方式でオーケーというだけで、タプルやマップや構造体を使用する場合はクォート式に変換してから渡す必要があります。

具体的には、下記のようなコードは、

defmodule Module1 do
  p = %{name: "hoge"}
  def func1(unquote(p)) do
    "func1 called"
  end
end
# => ** (CompileError) iex: invalid quoted expression: %{name: "hoge"}

上記のようにCompileErrorが発生します。正常にコンパイルするには、

defmodule Module1 do
  p = Macro.escape(%{name: "hoge"}) # エスケープする
  def func1(unquote(p)) do
    "func1 called"
  end
end

Module1.func1(%{name: "hoge"})
# => "func1 called"

というように記述する必要があります。

ご参考までに、各型がクォート式に変換された場合の出力結果を下記に記述しておきます。

quote do: 1
# => 1
quote do: "2"
# => "2"
quote do: [c: 3]
# => [c: 3]
quote do: {:d, 4, 4}
# => {:{}, [], [:d, 4, 4]}
quote do: %{e: 5}
# => {:%{}, [], [e: 5]}
defmodule F do defstruct name: "name" end
quote do: %F{}
# => {:%, [], [{:__aliases__, [alias: false], [:F]}, {:%{}, [], []}]}

また、func1に渡された引数pを参照したい場合、下記のようなコードはCompileErrorになります。

defmodule Module1 do
  p = Macro.escape(%{name: "hoge"})
  def func1(unquote(p)) do
    p
  end
end
# => ** (CompileError) iex:1033: undefined function p/0

関数内で変数を参照する場合、その変数は当然そのスコープ内に存在しなければなりません。

上記の場合、func1の引数pは展開されて%Module0{name: "kenta"}という状態になっていますので、このスコープ内にpという変数はまだ存在していません。そのためコンパイルエラーになるわけです。

正常にコンパイルするには、下記のように記述する必要があります。

defmodule Module1 do
  p = Macro.escape(%{name: "hoge"})
  def func1(unquote(p)) do
    unquote(p) # unquoteする
  end
end

Module1.func1(%{name: "hoge"})
# => %{name: "hoge"}

ちなみに、unquoteしなくても、func1内でpを「右辺」ではなく「左辺」に使った場合、エラーにはなりません。

defmodule Module1 do
  p = Macro.escape(%{name: "hoge"})
  def func1(unquote(p)) do
    p = "foo" # エラーにならない(pという新しいローカル変数が定義される)
  end
end

Module1.func1(%{name: "hoge"})
# => "foo"

この場合、単純にpというローカル変数がfunc1内に定義されるだけなので、コンパイルが正常に通る、ということになります。

上記と、以前解説させて頂いたvar!マクロを組み合わせると、下記のようなマクロが作成出来ます。

defmodule Module1 do
  defmacro macro1 p1 do
    quote do
      def func1(unquote(p1)) do
        var!(p1) = unquote(p1)
        IO.inspect "var!(p1) = #{inspect var!(p1)}"
        Module1.macro2
      end
    end
  end
  defmacro macro2 do
    quote do
      var!(p1) = "foo"
      IO.inspect "var!(p1) = #{inspect var!(p1)}"
    end
  end
end

defmodule Module2 do
  import Module1
  macro1 "hoge"
end

Module2.func1("hoge")
# "var!(p1) = \"hoge\""
# "var!(p1) = \"foo\""

この、unquoteした引数を、var!マクロを適用したnilコンテキスト変数に割り当てるという方法は、Phoenixのコードの一部で見受けられる方法ですが、この用法の目的は、

引数の値を制限した上で、ネストされるマクロからその値を参照出来るようにする

ということになるようです。

最後に

Elixirの内部的な挙動にまだあまり詳しくないので、かなり予測を含んだ考察になります。誤り等ありましたら是非ご指摘くださいm(__)m

(2016/03/16追記)
Metaprogramming Elixirによると、マクロ内で関数を定義する際に、関数名や関数の引数にunquoteを使う手法はunquote fragmentsと呼ばれているそうです。

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