Uploaded avatar of PercyGrunwald

Elixir 中的 Unicode 比對

@PercyGrunwald
7年多前

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"]
2019年03月03日 · 覺得有幫助嗎?