Les exercices d'Exercism sont petits, synthétiques et souvent d'apparence anodine. On imagine facilement que des développeurs expérimentés n'auraient rien à en apprendre. Pourtant, résoudre ces problèmes artificiels peut te pousser à apprendre et à appliquer des aspects de ton langage que tu n'as peut-être jamais explorés. Ces nouveaux acquis peuvent t'amener à résoudre des problèmes concrets de manière plus efficace ou plus expressive.
Parallel Letter Frequency est un exercice de difficulté moyenne du parcours Elixir d'Exercism qui dévoile un nombre surprenant de leçons intéressantes. Un des principaux défis de cet exercice consiste à gérer des lettres issues de plusieurs langues, car l'un des cas de test est rédigé en allemand et contient des caractères qui ne font pas partie de l'alphabet anglais. Si tu passes l'essentiel de ton temps à développer des applications pour des anglophones, c'est peut-être la première fois que tu dois répondre à ce genre d'exigence. Ce que tu apprends avec cet exercice est clairement utile à quiconque écrit une application multilingue ou non anglophone, mais peut aussi servir dans bien d'autres domaines, comme des validations plus robustes de noms d'utilisateur et de mots de passe.
Pour résoudre l'exercice, tu dois implémenter une fonction, Frequency.frequency/2, qui détermine la fréquence des lettres dans une liste de strings pouvant être dans n'importe quelle langue :
iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
"c" => 1,
"d" => 1,
"e" => 5,
...
"ö" => 2
}
Commençons par le problème fondamental que cette fonction doit résoudre, puis remontons jusqu'à une implémentation complète.
Déterminer si un caractère est une lettre en Elixir
Comment utiliserais-tu Elixir pour déterminer si "a" est une lettre ou non ?
Je pense que la plupart des gens utiliseraient une expression régulière comme /[a-z]/ :
iex> String.match?("a", ~r/^[a-z]$/)
true
Et pour "A" ?
Ajouter le modificateur i (insensible à la casse) serait probablement le plus simple :
iex> String.match?("A", ~r/^[a-z]$/i)
true
Bien, et pour "ö" ?
Quand j'ai abordé ce problème pour la première fois, je ne savais pas quelle était la meilleure approche. /[a-z]/i ne va clairement pas fonctionner :
iex> String.match?("ö", ~r/^[a-z]$/i)
false
Déterminer si "ö" est une lettre est un élément central de ce problème Exercism, car l'un des textes des tests est en allemand :
# 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
...
"""
Tu pourrais peut-être utiliser une expression régulière pour vérifier qu'un caractère n'est pas un caractère spécial, mais elle risque d'être longue, peu élégante et fragile. Peux-tu vraiment être sûr d'avoir couvert tous les caractères spéciaux susceptibles d'être passés en entrée à ta fonction ? Je pense qu'il existe une meilleure approche.
Les expressions régulières Unicode en Elixir
Une meilleure approche consiste à utiliser le modificateur u du module Regex d'Elixir :
unicode (
u) : active les motifs propres à Unicode comme\pet fait que des modificateurs tels que\w,\W,\set compagnie prennent eux aussi en charge Unicode.
Il s'avère que le modificateur u, et plus particulièrement le motif \p, est une solution vraiment élégante. Le motif \p permet de faire correspondre un graphème (autre nom d'un caractère Unicode unique) à n'importe laquelle des catégories de caractères Unicode. Cela inclut non seulement des catégories précises comme Ll (Lettre, minuscule) et Sc (Symbole, devise), mais aussi les catégories parentes comme L (Lettre) et S (Symbole).
Tu peux faire correspondre n'importe quelle lettre, quelle que soit sa casse, dans n'importe quelle langue humaine couverte par Unicode avec le motif \p{L}. Cela autorise des correspondances plutôt puissantes.
Les caractères latins de base de l'anglais fonctionnent comme d'habitude :
iex> String.match?("a", ~r/^\p{L}$/u)
true
iex> String.match?("A", ~r/^\p{L}$/u)
true
Les variantes de caractères latins avec des trémas et des accents aigus ne posent aucun problème non plus :
iex> String.match?("ö", ~r/^\p{L}$/u)
true
iex> String.match?("Á", ~r/^\p{L}$/u)
true
Vérifions qu'il ne renvoie pas simplement une correspondance pour n'importe quel caractère. Et pour des caractères qui ressemblent à des lettres sans en être :
iex> String.match?("$", ~r/^\p{L}$/u)
false
iex> String.match?("@", ~r/^\p{L}$/u)
false
Très bien, mais tu te souviens quand je disais n'importe quelle langue ? Pas de souci :
# Chinese character for "you"
iex> String.match?("你", ~r/^\p{L}$/u)
true
# Cyrillic capital letter "zhe"
iex> String.match?("Ж", ~r/^\p{L}$/u)
true
Appliquer la correspondance Unicode à notre problème
Maintenant que nous avons un outil capable de déterminer si un graphème est une lettre, nous pouvons l'utiliser pour résoudre le problème. Une première implémentation de la fonction Frequency.frequency/2 pourrait ressembler à ceci :
def frequency(texts, _workers) do
texts
|> get_all_graphemes()
|> count_letters()
end
defp get_all_graphemes(texts) do
texts
|> Enum.join()
|> String.graphemes()
end
Tout ce que count_letters/1 aurait à faire, c'est appliquer le motif String.match?(grapheme, ~r/^\p{L}$/u) que nous avons identifié plus haut pour incrémenter le compteur de chaque lettre de la liste de graphemes. Voici un exemple d'implémentation tiré de ma solution à ce problème 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
Cette fonction prend une liste de graphèmes, par exemple ["a", "A", "ö", "$"], et renvoie une map qui ne compte que les lettres en ignorant la casse, à savoir %{"a" => 2, "ö" => 1}. Quand on sait que cette fonction gère des entrées de n'importe quelle langue, on peut dire que ce sont 9 lignes de code plutôt puissantes.
Conclusion
Il s'avère que faire correspondre des lettres non anglaises devient assez simple quand on connaît la correspondance Unicode, et heureusement pour nous, c'est une fonctionnalité centrale du module Regex d'Elixir. Avant de résoudre ce problème Exercism, je connaissais à peine cette fonctionnalité, mais je la considère aujourd'hui comme un élément indispensable de ma boîte à outils Elixir.
Tu pourrais utiliser ce nouvel outil de bien des façons, et quelques-unes me viennent à l'esprit : une validation plus robuste des mots de passe et des noms d'utilisateur, ou même pour déterminer si une string donnée en entrée est une string de devise valide sans avoir à lister manuellement tous les symboles monétaires possibles :
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"]