Gli esercizi su Exercism sono piccoli, sintetici e spesso apparentemente banali. È facile immaginare che i professionisti esperti non abbiano nulla da imparare da loro. Tuttavia, risolvere questi problemi artificiali può spingerti a imparare e applicare parti del tuo linguaggio che potresti non aver mai esplorato. Queste nuove conoscenze possono portarti a risolvere problemi del mondo reale in modo più efficiente o più espressivo.
Parallel Letter Frequency è un esercizio di difficoltà media del track Elixir di Exercism che svela un numero sorprendente di lezioni interessanti. Una delle sfide centrali nel risolvere l'esercizio è gestire lettere provenienti da più lingue, dato che uno dei casi di test è in tedesco e contiene caratteri al di fuori dell'alfabeto inglese. Se passi la maggior parte del tuo tempo a sviluppare applicazioni per chi parla inglese, questa potrebbe essere la prima volta che devi affrontare un requisito del genere. Gli insegnamenti di questo esercizio offrono vantaggi evidenti a chi scrive applicazioni multilingue o in lingue diverse dall'inglese, ma possono essere utili anche in molti altri ambiti, come validazioni più robuste di nomi utente e password.
Per risolvere l'esercizio con successo, devi implementare una funzione, Frequency.frequency/2 , che determina la frequenza delle lettere in una lista di stringhe che potrebbero essere in qualsiasi lingua:
iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
"c" => 1,
"d" => 1,
"e" => 5,
...
"ö" => 2
}
Partiamo dal problema fondamentale che questa funzione deve risolvere e arriviamo passo dopo passo a un'implementazione completa.
Stabilire se un carattere è una lettera in Elixir
Come useresti Elixir per stabilire se "a" è una lettera oppure no?
Credo che la maggior parte delle persone userebbe un'espressione regolare come /[a-z]/:
iex> String.match?("a", ~r/^[a-z]$/)
true
E che dire di "A"?
Aggiungere il modificatore i (caseless) sarebbe probabilmente il modo più semplice:
iex> String.match?("A", ~r/^[a-z]$/i)
true
Ok, e adesso che dire di "ö"?
Quando ho affrontato questo problema per la prima volta, non ero sicuro di quale fosse il modo migliore: /[a-z]/i di sicuro non funzionerà:
iex> String.match?("ö", ~r/^[a-z]$/i)
false
Stabilire se "ö" è una lettera è una parte centrale per risolvere questo problema di Exercism, dato che uno dei testi nei test è in tedesco:
# 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
...
"""
Forse potresti usare un'espressione regolare per verificare che un carattere non sia un carattere speciale, ma probabilmente verrebbe lunga, inelegante e fragile. Quanto puoi essere sicuro di aver coperto ogni possibile carattere speciale che potrebbe essere passato come input alla tua funzione? Credo che ci sia un approccio migliore.
Le espressioni regolari Unicode in Elixir
Un approccio migliore a questo problema è usare il modificatore u nel modulo Regex di Elixir:
unicode (
u): abilita pattern specifici di Unicode come\pe modifica modificatori come\w,\W,\se simili in modo che corrispondano anch'essi a Unicode.
Si scopre che il modificatore u, e in particolare il pattern \p, è una soluzione davvero elegante. Il pattern \p ti permette di far corrispondere un grafema (un altro nome per un singolo carattere Unicode) a una qualsiasi delle categorie di caratteri Unicode. Questo include non solo categorie specifiche come Ll (Letter, lowercase) e Sc (Symbol, currency), ma anche le categorie padre come L (Letter) e S (Symbol).
Puoi far corrispondere qualsiasi lettera di qualsiasi caso in qualsiasi lingua umana coperta da Unicode con il pattern \p{L}. Questo permette corrispondenze piuttosto potenti.
I caratteri latini di base dell'inglese funzionano come al solito:
iex> String.match?("a", ~r/^\p{L}$/u)
true
iex> String.match?("A", ~r/^\p{L}$/u)
true
Anche le varianti dei caratteri latini con umlaut e accenti acuti non sono un problema:
iex> String.match?("ö", ~r/^\p{L}$/u)
true
iex> String.match?("Á", ~r/^\p{L}$/u)
true
Assicuriamoci che non restituisca una corrispondenza per qualsiasi carattere. Che ne dici di alcuni caratteri che sembrano lettere ma non lo sono:
iex> String.match?("$", ~r/^\p{L}$/u)
false
iex> String.match?("@", ~r/^\p{L}$/u)
false
Molto bene, ma ricordi quando ho detto qualsiasi lingua? Nessun problema:
# Chinese character for "you"
iex> String.match?("你", ~r/^\p{L}$/u)
true
# Cyrillic capital letter "zhe"
iex> String.match?("Ж", ~r/^\p{L}$/u)
true
Applicare la corrispondenza Unicode al problema in questione
Ora che abbiamo uno strumento che ci aiuta a stabilire se un grafema è una lettera oppure no, possiamo applicarlo per risolvere il problema. Una prima implementazione della funzione Frequency.frequency/2 potrebbe essere questa:
def frequency(texts, _workers) do
texts
|> get_all_graphemes()
|> count_letters()
end
defp get_all_graphemes(texts) do
texts
|> Enum.join()
|> String.graphemes()
end
Tutto ciò che count_letters/1 deve fare è applicare il pattern String.match?(grapheme, ~r/^\p{L}$/u) che abbiamo individuato sopra per incrementare il conteggio di ogni lettera nella lista di graphemes. Ecco un esempio di implementazione tratto dalla mia soluzione a questo problema di 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
Questa funzione accetta una lista di grafemi, ad esempio ["a", "A", "ö", "$"], e restituisce una mappa che conta solo le lettere ignorando il caso: %{"a" => 2, "ö" => 1}. Considerando che questa funzione può gestire input da qualsiasi lingua, direi che sono 9 righe di codice piuttosto potenti.
Conclusione
Si scopre che far corrispondere lettere non inglesi diventa piuttosto semplice quando conosci la corrispondenza Unicode e, per fortuna, è una funzionalità fondamentale del modulo Regex di Elixir. Prima di risolvere questo problema di Exercism conoscevo a malapena questa funzionalità, ma adesso la considererei una parte indispensabile del mio bagaglio di strumenti per Elixir.
Potresti usare questo nuovo strumento in molti modi; alcuni che mi vengono in mente sono una validazione più robusta di password e nomi utente, o persino per stabilire se una stringa di input è una stringa di valuta valida senza dover elencare manualmente tutti i possibili simboli di valuta:
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"]