本投稿は『fukuoka.ex Elixir/Phoenix Advent Calendar 2020』の2日目の記事です。
例えば、次のような関数があるとしましょう。
@type order :: {:price, :asc} | {:price, :desc} | :relevant | :recent | :review_rate
@type options :: {:price_range, lower :: integer, higher :: integer}
@spec search(String.t, order, list(options)) :: list(map)
def search(keyword, order, options) do
# do something
:ok
end
タイプは設定されており、入力が間違っていること書いた人は分かるが、使う人の立場では、
無効な値が入って来た時のエラーなのかロジックの問題なのか入力の問題なのか分かりにくいです。
直感的なエラーを返してたいからガードを入れてみましょう。
@orders [{:price, :asc}, {:price, :desc}, :relevant, :recent, :review_rate]
@type order :: {:price, :asc} | {:price, :desc} | :relevant | :recent | :review_rate
@type options :: {:price_range, lower :: integer, higher :: integer}
@spec search(String.t(), order, list(options)) :: list(map)
def search(keyword, order, options \\ []) when is_binary(keyword) and order in @orders do
# do something
:ok
end
これで間違った入力を入れると
test "search/3" do
assert search(1, :price)
end
次のようなエラーが出ます。
|| ** (FunctionClauseError) no function clause matching in ReadableTypeError.search/3
||
|| The following arguments were given to ReadableTypeError.search/3:
||
|| # 1
|| 1
||
|| # 2
|| :order
||
|| # 3
|| []
||
|| Attempted function clauses (showing 1 out of 1):
||
|| def search(keyword, order, options) when -is_binary(keyword)- and (-order === {:price, :asc}- or (-order === {:price, :desc}- or (-order === :relevant- or (-order === :recent- or -order === :review_rate-))))
入力が間違っだのは分かるが仕様を解釈するには、コードを読む必要があります。
この例では、引数の入力が短い方が、セッションとか大きなecto structが渡されたりsessionが渡されたりした場合には、バーの位置を見つけるも大変ですね。
when以降のmatchもコードと異なるので、さらに混乱しています。
これから出力を分かりやすくするために、マッチングエラーを使用せずに直接raiseしてみます。テストを最初に変えコードを変更します。
{:error, xxx}
リターンではなく、raiseを使う理由は、元の失敗した入力で、入力は正常範囲に入れないからです。
正常範囲に入れたい場合、{:error, xxx}
リターンであっても構いません。
test "search/3" do
assert_raise TypeError, "First argument keyword should be a string. given: 1" fn ->
search(1, {:order, :desc})
end
end
def search(keyword, _order, _options) do
case is_binary(keyword) do
true ->
nil
false ->
raise TypeError, "First argument keyword should be a string. given: #{inspect(keyword)}"
end
end
ここで二番目の引数もエラーが出るようにしてみましょう。
エラーが何度も出てくる場合全メッセージが出力されてほしいので、ここでEnum.mapを使用してエラーメッセージを作成するようします。
assert_raise TypeError, """
First argument keyword should be a string. given: 1
Second argument order should be one of [{:price, :asc}, {:price, :desc}, :relevant, :recent, :review_rate]. given: {:price, :abc}
""", fn ->
search(1, {:price, :abc})
end
def search(keyword, order, _options) do
message =
[
{keyword, &Kernel.is_binary/1,
&"First argument keyword should be a string. given: #{inspect(&1)}\n"},
{order, &(&1 in @orders),
&"Second argument order should be one of #{inspect(@orders)}. given: #{inspect(&1)}\n"}
]
|> Enum.map(fn {value, match_fn, message_fn} ->
case match_fn.(value) do
true -> ""
false -> message_fn.(value)
end
end)
|> Enum.join("")
raise TypeError, message
end
コードは、ますます汚れますが、エラーメッセージは大分の読めるようになりました。
しかし、二番目の引数のエラーメッセージは、まだ冗長ですね。もっと良い推薦のために
jaro distance(驚くことに標準関数!!)
で類似の値のみ出力する
did_you_mean?
関数を作成し使用してみましょう。
ここのコードは、elixirのコードから借りてきました。
test "search/3" do
assert_raise TypeError, """
First argument keyword should be a string. given: 1
Second argument order given: {:price, :abc}
Did you mean one of:
- {:price, :asc}
- {:price, :desc}
""", fn ->
search(1, {:price, :abc})
end
end
def search(keyword, order, _options) do
message =
[
{keyword, &Kernel.is_binary/1,
&"First argument keyword should be a string. given: #{inspect(&1)}\n"},
{order, &(&1 in @orders),
&"Second argument order given: #{inspect(&1)}\n#{did_you_mean(&1, @orders)}"}
]
|> Enum.map(fn {value, match_fn, message_fn} ->
case match_fn.(value) do
true -> ""
false -> message_fn.(value)
end
end)
|> Enum.join("")
raise TypeError, message
end
@function_threshold 0.77
@max_suggestions 5
defp did_you_mean(given, candidates) do
result =
for key <- candidates,
dist = String.jaro_distance(inspect(given), inspect(key)),
dist >= @function_threshold do
{dist, key}
end
|> Enum.sort(&(elem(&1, 0) >= elem(&2, 0)))
|> Enum.take(@max_suggestions)
|> Enum.sort(&(elem(&1, 1) <= elem(&2, 1)))
case result do
[] ->
""
suggestions ->
["Did you mean one of:\n\n" | Enum.map(suggestions, &"- #{inspect(elem(&1, 1))}\n")]
|> Enum.join("")
end
end
思ったより長くなったので、ここで一度切って行こうとします。
まだ@spec
@type
から型情報を自動入力するとか、ガード関数を定型化するとかする改善は必要ですが、このようにメッセージを変えて読みやすく作成する方法もあるんだ程度で読んでいただければ幸いです。