Exercismの演習は小さく、人工的で、しばしば取るに足らないように見えます。経験豊富なエンジニアなら、そこから学ぶことは何もないと想像しがちです。しかし、こうした人工的な問題を解くことで、これまで触れてこなかった言語の機能を学び、応用するよう促されることがあります。こうした新しい学びは、現実世界の問題をより効率的に、あるいはより表現豊かに解くことにつながります。
並列文字頻度は、ExercismのElixirトラックにある中級難易度の演習で、驚くほど多くの興味深い教訓を引き出してくれます。この演習を解くうえでの中心的な課題は、複数の言語の文字を扱うことです。テストケースの1つがドイツ語で、英語のアルファベット以外の文字を含んでいるからです。英語話者向けのアプリケーション開発にほとんどの時間を費やしているなら、このような要件に対処するのは初めてかもしれません。この演習からの学びは、多言語または英語以外のアプリケーションを書く人にとって明らかなメリットがありますが、より堅牢なユーザー名やパスワードの検証など、ほかの多くの分野にも役立ちます。
この演習をうまく解くには、どの言語のものでもあり得る文字列の配列から文字の頻度を求める関数、Frequency.frequency/2を実装する必要があります:
iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
"c" => 1,
"d" => 1,
"e" => 5,
...
"ö" => 2
}
この関数が解くべき根本的な問題から始めて、完全な実装へと進めていきましょう。
Elixirで、ある文字が文字カテゴリに属するかどうかを判定する
Elixirで"a"が文字かどうかを判定するには、どうしますか?
ほとんどの人は/[a-z]/のような正規表現を使うと思います:
iex> String.match?("a", ~r/^[a-z]$/)
true
"A"はどうでしょうか?
iの(大文字小文字を区別しない)修飾子を付けるのが、おそらくいちばん簡単でしょう:
iex> String.match?("A", ~r/^[a-z]$/i)
true
では、"ö"はどうでしょうか?
私が最初にこの問題に取り組んだとき、最適な方法がわかりませんでした。/[a-z]/iでは絶対にうまくいきません:
iex> String.match?("ö", ~r/^[a-z]$/i)
false
"ö"が文字かどうかを判定することは、このExercismの演習問題を解くうえで核心的な部分です。テスト内のテキストの1つがドイツ語だからです:
# parallel_letter_frequency_test.exs
...
# Poem by Friedrich Schiller. The corresponding music is the European Anthem.
@ode_an_die_freude """
Freude schöner Götterfunken
...
"""
特殊文字でないことを確認する正規表現を使う方法もあるかもしれませんが、長く、洗練されておらず、壊れやすいものになりがちです。関数への入力として渡される可能性のある特殊文字をすべて網羅できたと、どれだけ自信を持てるでしょうか? もっと良い方法があると私は考えています。
ElixirのUnicode正規表現
この問題に対するより良い方法は、ElixirのRegexモジュールのu修飾子を使うことです:
unicode(
u)は、\pのようなUnicode固有のパターンを有効にし、\w、\W、\sなどの修飾子をUnicodeにもマッチするように変更します。
u修飾子、とくに\pパターンは、とてもエレガントな解決策であることがわかります。
\pパターンを使うと、Unicode文字カテゴリのいずれかに属する書記素(単一のUnicode文字の別名)をマッチさせられます。これには、Ll(文字、小文字)やSc(記号、通貨)のような特定のカテゴリだけでなく、L(文字)やS(記号)のような親カテゴリも含まれます。
\p{L}というパターンを使えば、Unicodeが扱うあらゆる人間の言語の、_あらゆる_大文字小文字を問わず、_あらゆる_文字にマッチさせられます。これにより、かなり強力なマッチングが可能になります。
英語の基本ラテン文字は通常どおり動作します:
iex> String.match?("a", ~r/^\p{L}$/u)
true
iex> String.match?("A", ~r/^\p{L}$/u)
true
ウムラウトやアキュートアクセントが付いたラテン文字の異形も問題ありません:
iex> String.match?("ö", ~r/^\p{L}$/u)
true
iex> String.match?("Á", ~r/^\p{L}$/u)
true
どんな文字にもマッチしているわけではないことを確認しましょう。文字のように見えて文字でないものはどうでしょうか:
iex> String.match?("$", ~r/^\p{L}$/u)
false
iex> String.match?("@", ~r/^\p{L}$/u)
false
素晴らしいですね。でも、_あらゆる_言語と言ったのを覚えていますか? 心配いりません:
# Chinese character for "you"
iex> String.match?("你", ~r/^\p{L}$/u)
true
# Cyrillic capital letter "zhe"
iex> String.match?("Ж", ~r/^\p{L}$/u)
true
Unicodeマッチングをこの問題に応用する
書記素が文字かどうかを判定するのに役立つ道具が手に入ったので、それを問題の解決に応用できます。Frequency.frequency/2関数の最初の実装は、次のようになるでしょう:
def frequency(texts, _workers) do
texts
|> get_all_graphemes()
|> count_letters()
end
defp get_all_graphemes(texts) do
texts
|> Enum.join()
|> String.graphemes()
end
count_letters/1がする必要があるのは、先ほど見つけたString.match?(grapheme, ~r/^\p{L}$/u)パターンを適用して、graphemesの配列内の各文字のカウントを増やすことだけです。こちらは、このExercismの演習問題に対する私の解答から抜粋した実装例です:
defp count_letters(graphemes) do
Enum.reduce(graphemes, %{}, fn grapheme, acc ->
if String.match?(grapheme, ~r/^\p{L}$/u) do
downcased_letter = String.downcase(grapheme)
Map.update(acc, downcased_letter, 1, fn count -> count + 1 end)
else
acc
end
end)
end
この関数は書記素の配列、たとえば["a", "A", "ö", "$"]を受け取り、大文字小文字を無視して文字だけを数えたマップ、%{"a" => 2, "ö" => 1}を返します。あらゆる言語の入力を扱えることを考えると、これはかなり強力な9行のコードだと思います。
まとめ
Unicodeマッチングを知っていれば、英語以外の文字のマッチングはとても簡単になります。幸いなことに、これはElixirのRegexモジュールのコア機能です。このExercismの演習問題を解く前は、この機能をほとんど知りませんでした。しかし今では、私のElixirツールボックスになくてはならない一部だと考えています。
この新しい道具はさまざまな場面で使えます。私がすぐに思いつくのは、パスワードやユーザー名のより堅牢な検証や、考えられるすべての通貨記号を手動で列挙することなく、入力文字列が有効な通貨文字列かどうかを判定することです:
iex> currency_string_regex = ~r/\p{Sc}\d+\.\d{2}/u
~r/\p{Sc}\d+\.\d{2}/u
iex> ["$1.00", "£1.00", "¥1.00", "€1.00", "&1.00"] \
...> |> Enum.filter(&String.match?(&1, currency_string_regex))
["$1.00", "£1.00", "¥1.00", "€1.00"]