Los ejercicios de Exercism son pequeños, sintéticos y, a menudo, aparentemente triviales. Es fácil imaginar que los profesionales con experiencia no tendrían nada que aprender de ellos. Sin embargo, resolver estos problemas artificiales puede empujarte a aprender y aplicar partes de tu lenguaje que quizá no hayas explorado. Este nuevo aprendizaje puede llevarte a resolver problemas del mundo real de forma más eficiente o más expresiva.
Frecuencia de letras en paralelo es un ejercicio de dificultad media de la pista de Elixir de Exercism que desglosa una sorprendente cantidad de lecciones interesantes. Un reto central al resolver el ejercicio es manejar letras de varios idiomas, ya que uno de los casos de prueba está en alemán y contiene caracteres fuera del alfabeto inglés. Si pasas la mayor parte del tiempo desarrollando aplicaciones para personas que hablan inglés, puede que sea la primera vez que tengas que lidiar con un requisito como este. Lo que se aprende con este ejercicio tiene beneficios claros para cualquiera que escriba una aplicación multilingüe o no inglesa, pero también podría ayudar en muchas otras áreas, como validaciones de nombres de usuario y contraseñas más robustas.
Para resolver el ejercicio correctamente, necesitas implementar una función, Frequency.frequency/2, que determina la frecuencia de las letras en una lista de strings que podría estar en cualquier idioma:
iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
"c" => 1,
"d" => 1,
"e" => 5,
...
"ö" => 2
}
Empecemos por el problema fundamental que esta función debe resolver y vayamos subiendo hasta llegar a una implementación completa.
Determinar si un carácter es una letra en Elixir
¿Cómo usarías Elixir para determinar si "a" es una letra o no?
Creo que la mayoría de la gente aplicaría una expresión regular como /[a-z]/:
iex> String.match?("a", ~r/^[a-z]$/)
true
¿Y "A"?
Añadir el i, el modificador sin distinción de mayúsculas y minúsculas, sería probablemente la forma más sencilla:
iex> String.match?("A", ~r/^[a-z]$/i)
true
Vale, ¿y "ö"?
Cuando me enfrenté por primera vez a este problema, no tenía claro cuál era la mejor forma; /[a-z]/i desde luego no va a funcionar:
iex> String.match?("ö", ~r/^[a-z]$/i)
false
Determinar si "ö" es una letra es una parte fundamental para resolver este problema de Exercism, ya que uno de los textos de las pruebas está en alemán:
# 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
...
"""
Quizá podrías usar una expresión regular para comprobar que un carácter no es un carácter especial, pero es probable que sea larga, poco elegante y frágil. ¿Hasta qué punto puedes estar seguro de que has cubierto todos los posibles caracteres especiales que podrían pasarse como entrada a tu función? Creo que hay un enfoque mejor.
Expresiones regulares Unicode en Elixir
Un enfoque mejor para este problema es usar el modificador u del módulo Regex de Elixir:
unicode (
u) - habilita patrones específicos de Unicode como\py cambia modificadores como\w,\W,\sy compañía para que también coincidan con Unicode.
Resulta que el modificador u, y en concreto el patrón \p, es una solución realmente elegante. El patrón \p te permite coincidir con un grafema (otro nombre para un solo carácter Unicode) en cualquiera de las categorías de caracteres Unicode. Esto no solo incluye categorías específicas como Ll (letra minúscula) y Sc (símbolo de moneda), sino también las categorías superiores como L (letra) y S (símbolo).
Puedes coincidir con cualquier letra de cualquier combinación de mayúsculas y minúsculas en cualquier idioma humano cubierto por Unicode con el patrón \p{L}. Esto permite coincidencias bastante potentes.
Los caracteres latinos básicos del inglés funcionan como de costumbre:
iex> String.match?("a", ~r/^\p{L}$/u)
true
iex> String.match?("A", ~r/^\p{L}$/u)
true
Las variantes de caracteres latinos con diéresis y acentos agudos tampoco son problema:
iex> String.match?("ö", ~r/^\p{L}$/u)
true
iex> String.match?("Á", ~r/^\p{L}$/u)
true
Asegurémonos de que no devuelve una coincidencia para cualquier carácter. ¿Qué pasa con algunos caracteres que parecen letras pero no lo son?
iex> String.match?("$", ~r/^\p{L}$/u)
false
iex> String.match?("@", ~r/^\p{L}$/u)
false
Muy bien, pero ¿recuerdas cuando dije cualquier idioma? Sin 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
Aplicar la coincidencia Unicode al problema que nos ocupa
Ahora que tenemos una herramienta que puede ayudarnos a determinar si un grafema es una letra o no, podemos aplicarla para resolver el problema. Una implementación inicial de la función Frequency.frequency/2 podría ser así:
def frequency(texts, _workers) do
texts
|> get_all_graphemes()
|> count_letters()
end
defp get_all_graphemes(texts) do
texts
|> Enum.join()
|> String.graphemes()
end
Todo lo que tendría que hacer count_letters/1 es aplicar el patrón String.match?(grapheme, ~r/^\p{L}$/u) que identificamos antes para incrementar el recuento de cada letra en la lista de graphemes. Aquí tienes una implementación de ejemplo tomada de mi solución a este problema de 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
Esta función acepta una lista de grafemas, por ejemplo, ["a", "A", "ö", "$"], y devuelve un mapa que cuenta solo las letras e ignora la distinción entre mayúsculas y minúsculas: %{"a" => 2, "ö" => 1}. Teniendo en cuenta que esta función puede manejar entradas de cualquier idioma, yo diría que son 9 líneas de código bastante potentes.
Conclusión
Resulta que coincidir con letras que no son inglesas se vuelve bastante sencillo cuando conoces la coincidencia Unicode y, por suerte para nosotros, es una funcionalidad central del módulo Regex de Elixir. Antes de resolver este problema de Exercism apenas conocía esta funcionalidad, pero ahora la consideraría una parte indispensable de mi caja de herramientas de Elixir.
Podrías usar esta nueva herramienta de muchas formas, y algunas que se me ocurren son validaciones más robustas de contraseñas y nombres de usuario, o incluso para determinar si un string de entrada es un string de moneda válido sin necesidad de enumerar manualmente todos los posibles símbolos de moneda:
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"]