Uploaded avatar of PercyGrunwald

Зіставлення Unicode в Elixir

@PercyGrunwald
Більше 7 років тому

Вправи на Exercism невеликі, синтетичні й часто здаються тривіальними. Легко уявити, що досвідченим практикам нема чого з них почерпнути. Проте розвʼязування цих штучних задач може спонукати нас вивчати й застосовувати ті елементи мови програмування, які ми, можливо, ще не досліджували. Це нове знання може допомогти нам розвʼязувати реальні задачі ефективніше й виразніше.

Паралельна частота літер - вправа середньої складності на треку Elixir від Exercism, яка розкриває напрочуд багато цікавих уроків. Головна складність у розвʼязанні цієї вправи - робота з літерами з різних мов, адже один із тестових випадків написаний німецькою і містить символи поза англійським алфавітом. Якщо ми проводимо більшість часу за розробкою застосунків для англомовних користувачів, це може бути перший випадок, коли нам доводиться мати справу з такою вимогою. Знання з цієї вправи має очевидну користь для всіх, хто пише багатомовний або неангломовний застосунок, але може допомогти й у багатьох інших сферах, наприклад у надійнішій перевірці імен користувачів і паролів.

Щоб успішно розвʼязати вправу, треба реалізувати функцію Frequency.frequency/2, яка визначає частоту літер у масиві рядків тексту (англ. string), що можуть бути будь-якою мовою:

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
...
"""

Можливо, ми могли б скористатися регулярним виразом, щоб перевірити, чи символ не є спеціальним символом, але такий вираз, імовірно, буде довгим, невитонченим і крихким. Наскільки ми впевнені, що охопили всі можливі спеціальні символи, які можуть потрапити на вхід нашої функції? Я вважаю, що є кращий підхід.

Регулярні вирази Unicode в Elixir

Кращий підхід до цієї проблеми - скористатися модифікатором u у модулі Regex в Elixir:

unicode (u) - вмикає специфічні для Unicode шаблони, як-от \p, і змінює модифікатори на кшталт \w, \W, \s та подібні до них так, щоб вони теж збігалися з Unicode.

Виявляється, що модифікатор u і зокрема шаблон \p - це справді елегантне рішення. Шаблон \p дає змогу зіставити графему (інша назва одного символу Unicode) з будь-якою з категорій символів Unicode. Це охоплює не лише конкретні категорії, як-от Ll (літера, нижній регістр) і Sc (символ, валюта), а й батьківські категорії, як-от L (літера) і S (символ).

Ми можемо зіставити будь-яку літеру будь-якого регістру будь-якої людської мови, охопленої Unicode за допомогою шаблону \p{L}. Це дає змогу робити доволі потужні зіставлення.

Базові латинські символи з англійської працюють як завжди:

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, і, на наше щастя, це основна можливість модуля Regex в Elixir. До розвʼязання цієї задачі з 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"]
Translation missing: uk.number.nth.ordinalized Mar 2019 · Виявилося корисним?