モチベーション
Elixirの関数の引数の意味は位置だけで決まります。たまに関数の名称から各引数の役割が読みにくく戸惑うことがありました。
Enum.reverse/1
を例にとります。
Enum.reverse([1, 2, 3])
の結果が[3, 2, 1]
になることは容易に想像できると思います。この場合は特に問題なしです。
一方で、Enum.reverse/2
、たとえば
Enum.reverse([1, 2, 3], [4, 5, 6])
については、日常的にElixirを使い続けている人でなければ、ドキュメンテーションなしには結果が予想しにくいのではと思います。
結果は[3, 2, 1, 4, 5, 6]
で、第1引数 (enumerable
) を逆順にし、その後ろに第2引数 (tail
) を結合したリストになります。特に関数名に紐づかない処理なので、一見わかりませんよね。。
この動作をコードをぱっとみただけでわかるようにしたい!
たとえばPythonの場合
まず思い出したのはPythonのキーワード引数でした。
たとえば、csv.DictWriter
のコンストラクタの第2引数には、各カラムの名称を与える必要があります。
writer = csv.DictWriter(sys.stdout, ['a', 'b', 'c'])
(引数の絵面からなんとなく意味がわかってしまい、悪い例かもしれません。。)
これは下記のようにも書くことができます。
writer = csv.DictWriter(sys.stdout, fieldnames=['a', 'b', 'c'])
引数名がコードに明示されるので、役割が明確になりますね。
Elixirの場合
こういったことがElixirでもできないか?と悩む日々を過ごしていましたが、Access
モジュールのソースコード を読んでいたときに、これだ!という書き方を見つけました。
defp all(:get_and_update, data, next) when is_list(data) do
all(data, next, _gets = [], _updates = [])
end
_gets
と_updates
を用いて、引数の意味を表しています。アンダースコアで始まる変数は以後使われないので、機能的な影響はないと考えられます。
気になる点
これで引数の意味がわかりやすくなりました!が、デメリットがないか考察してみました。
ネーミング
Pythonのキーワード引数は名前を完全に一致させないとTypeError
になりますが、Elixirで先ほどのやりかたをしようとすると、_gets
のようなネーミングは書き手のセンスになります。
そもそもわかりやすくする
自作の関数であれば、そもそもわかりにくい関数名をつけない、雑多な引数はよくあるopts
のようなキーワードリストにする、などの工夫もできるかもしれません。
パフォーマンス
引数名をつけない場合とつける場合でパフォーマンスを比較してみました。
計測用コードはこちら
defmodule Argtest do
@n_iter 300_000_000
defp fun_with_many_args(a1, a2, a3, a4, a5) do
a1 + a2 + a3 + a4 + a5
end
def timeit_plain do
:timer.tc(fn ->
Enum.each(1..@n_iter, &fun_with_many_args(&1, 2, 3, 4, 5))
end)
|> elem(0)
|> IO.inspect(label: "plain")
end
def timeit_annot do
:timer.tc(fn ->
Enum.each(1..@n_iter, &fun_with_many_args(&1, _a2 = 2, _a3 = 3, _a4 = 4, _a5 = 5))
end)
|> elem(0)
|> IO.inspect(label: "annot")
end
end
結果は下記の通りで、実行時間に変わりはないようです。引数名の変数は未使用であることがわかっているので、最適化の段階で削除されているのでしょうか。
# 引数名をつけない
> MIX_ENV=prod mix run -e Argtest.timeit_plain
plain: 17667837
# つける
> MIX_ENV=prod mix run -e Argtest.timeit_annot
annot: 17418437
なお、iexでは引数名をつけたほうが実行に時間がかかりました。
iex> fun_with_many_args = fn a1, a2, a3, a4, a5 -> a1 + a2 + a3 + a4 + a5 end
iex> :timer.tc(fn -> Enum.each(1..1_000_000, & fun_with_many_args.(&1, 1, 2, 3, 4)) end)
{7086417, :ok}
iex> :timer.tc(fn -> Enum.each(1..1_000_000, & fun_with_many_args.(&1, _a1 = 1, _a2 = 2, _a3 = 3, _a4 = 4)) end)
{9065831, :ok}
まとめ
引数の意味がわかりにくい関数は
iex> Enum.reverse([1, 2, 3], _tail = [4, 5, 6])
[3, 2, 1, 4, 5, 6]
のように引数に「アンダースコア + 引数名」をつけて呼び出すとわかりやすい!