Uploaded avatar of PercyGrunwald

Correspondência de Unicode em Elixir

@PercyGrunwald
Mais de 7 anos atrás

Exercícios no Exercism são pequenos, sintéticos e, muitas vezes, aparentemente triviais. É fácil imaginar que profissionais experientes não tenham nada a aprender com eles. No entanto, resolver esses problemas artificiais pode te incentivar a aprender e aplicar partes da sua linguagem que você talvez nunca tenha explorado. Esse novo aprendizado pode te levar a resolver problemas do mundo real com mais eficiência ou mais expressividade.

Frequência de Letras em Paralelo é um exercício de dificuldade média na Trilha de Elixir do Exercism que revela um número surpreendente de lições interessantes. Um dos desafios centrais para resolver o exercício é lidar com letras de vários idiomas, já que um dos casos de teste está em alemão e contém caracteres que não fazem parte do alfabeto inglês. Se você passa a maior parte do tempo desenvolvendo aplicações para falantes de inglês, essa pode ser a primeira vez que você precisa lidar com um requisito assim. O aprendizado desse exercício traz benefícios claros para quem escreve aplicações multilíngues ou que não sejam em inglês, mas também pode ajudar em muitas outras áreas, como validações de nome de usuário e senha mais robustas.

Para resolver o exercício com sucesso, você precisa implementar uma função, Frequency.frequency/2, que determina a frequência de letras em uma lista de strings que podem estar em qualquer idioma:

iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
  "c" => 1, 
  "d" => 1, 
  "e" => 5, 
  ...
  "ö" => 2
}

Vamos começar pelo problema fundamental que essa função precisa resolver e ir avançando até chegar a uma implementação completa.

Determinar se um caractere é uma letra em Elixir

Como você usaria Elixir para determinar se "a" é uma letra?

Acho que a maioria das pessoas usaria uma expressão regular como /[a-z]/:

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

E quanto a "A"?

Adicionar o modificador i (caseless) provavelmente seria o jeito mais fácil:

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

Ok, e agora quanto a "ö"?

Quando abordei esse problema pela primeira vez, não tinha certeza de qual seria o melhor caminho. Usar /[a-z]/i definitivamente não vai funcionar:

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

Determinar se "ö" é uma letra é uma parte central da solução desse problema do Exercism, já que um dos textos dos testes está em alemão:

# 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
...
"""

Talvez você pudesse usar uma expressão regular para verificar se um caractere não é um caractere especial, mas ela provavelmente seria longa, deselegante e frágil. Qual a sua confiança de que você cobriu todos os caracteres especiais possíveis que podem ser passados como entrada para a sua função? Acredito que exista uma abordagem melhor.

Expressões regulares Unicode em Elixir

Uma abordagem melhor para esse problema é usar o modificador u do módulo Regex do Elixir:

unicode (u) - habilita padrões específicos do Unicode, como \p, e faz modificadores como \w, \W, \s e afins também corresponderem a caracteres Unicode.

Acontece que o modificador u, e especificamente o padrão \p, é uma solução realmente elegante. O padrão \p permite encontrar um grafema (outro nome para um único caractere Unicode) em qualquer uma das categorias de caracteres Unicode. Isso inclui não só categorias específicas como Ll (Letra, minúscula) e Sc (Símbolo, moeda), mas também as categorias superiores como L (Letra) e S (Símbolo).

Você pode encontrar qualquer letra, maiúscula ou minúscula, em qualquer idioma humano coberto pelo Unicode com o padrão \p{L}. Isso permite uma correspondência bastante poderosa.

Os caracteres latinos básicos do inglês funcionam como de costume:

iex> String.match?("a", ~r/^\p{L}$/u)
true
iex> String.match?("A", ~r/^\p{L}$/u)
true

Variantes de caracteres latinos com trema e acentos agudos também não são problema:

iex> String.match?("ö", ~r/^\p{L}$/u)
true
iex> String.match?("Á", ~r/^\p{L}$/u)
true

Vamos verificar se ele não está apenas retornando correspondência para qualquer caractere. E quanto a alguns caracteres que parecem letras, mas não são:

iex> String.match?("$", ~r/^\p{L}$/u)
false
iex> String.match?("@", ~r/^\p{L}$/u)
false

Muito bom, mas você lembra que eu disse qualquer idioma? Sem 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

Aplicando a correspondência Unicode ao problema em questão

Agora que temos uma ferramenta que pode nos ajudar a determinar se um grafema é uma letra, podemos aplicá-la para resolver o problema. Uma implementação inicial da função Frequency.frequency/2 pode ser mais ou menos assim:

def frequency(texts, _workers) do
  texts
  |> get_all_graphemes()
  |> count_letters()
end

defp get_all_graphemes(texts) do
  texts
  |> Enum.join()
  |> String.graphemes()
end

Tudo o que count_letters/1 precisaria fazer é aplicar o padrão String.match?(grapheme, ~r/^\p{L}$/u) que identificamos acima para incrementar a contagem de cada letra na lista de graphemes. Aqui está um exemplo de implementação, tirado da minha solução para esse problema do 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

Essa função recebe uma lista de grafemas, como ["a", "A", "ö", "$"], e retorna um mapa que conta apenas as letras, ignorando a caixa: %{"a" => 2, "ö" => 1}. Considerando que essa função consegue lidar com entradas de qualquer idioma, eu diria que são 9 linhas de código bastante poderosas.

Conclusão

Acontece que encontrar letras que não são do inglês fica bem simples quando você conhece a correspondência Unicode e, felizmente para nós, ela é um recurso central do módulo Regex do Elixir. Antes de resolver esse problema do Exercism, eu mal conhecia esse recurso, mas agora o consideraria uma parte indispensável da minha caixa de ferramentas de Elixir.

Você pode usar essa nova ferramenta de várias formas, e algumas que me vêm à cabeça são validações mais robustas de senhas e nomes de usuário, ou até para determinar se uma string de entrada é uma string de moeda válida sem precisar listar manualmente todos os símbolos de moeda possíveis:

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"]
Translation missing: pt-BR.number.nth.ordinalized Mar 2019 · Achou útil?