Uploaded avatar of PercyGrunwald

Coincidencia de Unicode en Elixir

@PercyGrunwald
hace Más de 7 años

Los ejercicios de Exercism son pequeños, sintéticos y, a menudo, aparentemente triviales. Es fácil imaginar que quienes ya tienen experiencia no tendrían nada que aprender de ellos. Sin embargo, resolver estos problemas artificiales puede llevarte a aprender y aplicar partes de tu lenguaje que quizás 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 en la ruta de Elixir de Exercism que desentraña una cantidad sorprendente de lecciones interesantes. Un desafío central a la hora de 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 de tu 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 aprendes con este ejercicio tiene beneficios claros para quien escribe una aplicación multilingüe o que no esté en inglés, pero también puede ayudar en muchas otras áreas, como validaciones más robustas de nombres de usuario y contraseñas.

Para resolver el ejercicio con éxito, necesitas implementar una función, Frequency.frequency/2, que determine la frecuencia de las letras en una lista de strings que podrían 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 necesita resolver y avancemos 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 qué hay de "A"?

Agregar el modificador i (sin distinción de mayúsculas y minúsculas) probablemente sería la forma más sencilla:

iex> String.match?("A", ~r/^[a-z]$/i)
true

Bien, ¿y qué hay de "ö"?

Cuando me enfrenté por primera vez a este problema, no tenía claro cuál era la mejor manera; /[a-z]/i definitivamente no va a funcionar:

iex> String.match?("ö", ~r/^[a-z]$/i)
false

Determinar si "ö" es una letra o no es una parte central de resolver este problema de Exercism, ya que uno de los textos en 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ás podrías usar una expresión regular para comprobar si un carácter no es un carácter especial, pero probablemente sea larga, poco elegante y frágil. ¿Cuánta confianza puedes tener en que cubriste todos los posibles caracteres especiales que podrían pasarse como argumento a tu función? Creo que hay un mejor enfoque.

Expresiones regulares Unicode en Elixir

Un mejor enfoque para este problema es usar el modificador u del módulo Regex de Elixir:

unicode (u): habilita patrones específicos de Unicode como \p y hace que modificadores como \w, \W, \s y similares 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 hacer coincidir un grafema (otro nombre para un solo carácter Unicode) con 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, moneda), sino también las categorías superiores como L (letra) y S (símbolo).

Puedes hacer coincidir cualquier letra, sea cual sea su caso, de cualquier idioma humano cubierto por Unicode con el patrón \p{L}. Esto permite lograr 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 solo devuelve una coincidencia para cualquier carácter. ¿Qué tal 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 nos puede ayudar 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 verse 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 conteo 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 las 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 hacer coincidir letras que no son del inglés se vuelve bastante simple cuando conoces las coincidencias 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 maneras, 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 representa un valor de moneda válido, sin necesidad de listar 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"]
domingo, 03 de marzo de 2019 · ¿Te resultó útil?