Elixirの関数呼び出しで引数の意味をわかりやすくする


モチベーション

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モジュールのソースコード を読んでいたときに、これだ!という書き方を見つけました。


access.ex

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]

のように引数に「アンダースコア + 引数名」をつけて呼び出すとわかりやすい!