Вправи на 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"]