Exercism 上的練習都很小、是人造設計出來的,而且往往看似微不足道。你可能會想,經驗老到的開發者大概沒什麼能從這些練習學到。不過,解這些刻意設計的題目,可以促使你去學習並運用自己或許還沒探索過的語言面向。這些新學到的東西,能讓你更有效率、或更貼切地表達來解決真實世界的問題。
平行字母頻率是 Exercism 的 Elixir 軌道上的一道中等難度練習,內容蘊含了出乎意料多的有趣課題。解這道練習時,核心挑戰之一是要處理來自多種語言的字母,因為其中一個測試案例是德文,裡面含有英文字母以外的字元。如果你大部分時間都在為英語使用者開發應用程式,這可能就是你第一次得處理這類需求。從這道練習學到的東西,對任何在寫多語言/非英語應用程式的人來說都很有幫助,也可能在許多其他領域派上用場,例如更穩健的使用者名稱與密碼驗證。
要順利解出這道練習,你需要實作一個函式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 題目的核心環節,因為測試中的其中一段文字是德文:
# 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", "ö", "$"],並回傳一個只計算字母、忽略大小寫的 map,也就是%{"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"]