Executando verificação de segurança...
28

Quando inverter uma string ou saber seu tamanho deixa de ser simples (ou: Unicode e suas bizarrices 3)

Este post é mais um da série de "bizarrices do Unicode" que estou escrevendo. Os anteriores são:

Primeiramente, se vc está trabalhando apenas com strings em ASCII, ou textos simples em português, pode ser que estes problemas não ocorram. Mas não dá para ignorar que hoje em dia não é tão improvável assim que vc trabalhe em sistemas que usam outros idiomas, ou que precise lidar com emojis e outros caracteres "estranhos". Então vamos lá.

Considere este texto:

💩 não são 🇦🇺

Qual o tamanho desta string, e como faço para invertê-la (ou seja, para que se transforme em 🇦🇺 oãs oãn 💩)?

Se vc pesquisar por aí, vai encontrar milhares de links como esse, que até funcionam para a maioria dos casos, mas falham para a string acima. Por exemplo, em JavaScript ficaria assim:

var texto = '💩 não são 🇦🇺';
console.log(Array.from(texto).reverse().join('')); // 🇺🇦 õas oãn 💩
console.log(texto.length); // 16

Repare que ao inverter a string, a bandeira da Austrália virou a da Ucrânia, e o til da palavra "são" ficou em cima da letra "o". E o tamanho da string é 16, embora hajam apenas 11 caracteres (considerando que cada emoji conta como 1 caractere).

Se fizermos em Python, temos a mesma string, porém o tamanho é diferente:

texto = '💩 não são 🇦🇺'
print(texto[::-1]) # 🇺🇦 õas oãn 💩
print(len(texto))  # 13

Vamos ficar apenas nessas duas linguagens, mas em outras também acontece coisas parecidas (não inverte corretamente e o tamanho varia).

Por que isso acontece?

Antes precisamos saber o que de fato tem nessas strings, e o que queremos definir como "caractere".

No Unicode existe o conceito de code point: é um valor numérico associado a um determinado caractere. Por exemplo, a letra "A" maiúscula possui o code point 65, e o emoji "💩" (PILE OF POO) possui o code point 128169. Mas a notação do Unicode diz que devemos escrevê-los em hexadecimal e com o prefixo "U+", ou seja, a letra A corresponde a U+0041 e o PILE OF POO, U+1F4A9.

Então para o Unicode, uma string seria uma sequência de um ou mais code points. Mas tem um detalhe: se eu gravo esta string em um arquivo, quais serão os bytes gravados? O Unicode não define isso, pois quem cuida desta parte são os encodings. Existem vários, como ISO-8859-1, UTF-8, UTF-16, etc. Cada um tem seu próprio algoritmo para converter um code point de/para bytes. E tem alguns pontos importantes:

  • nem todo encoding consegue converter todos os code points
  • dependendo do encoding escolhido, o mesmo code point pode resultar em bytes com valores e quantidades completamente diferentes

Para mais detalhes, recomendo ler aqui (e siga também todos os links que tem lá para se aprofundar - principalmente este - pois é um assunto bem amplo).


Isso explica parte do problema, pois cada linguagem usa um critério diferente para determinar o tamanho da string. Python usa a quantidade de code points (ainda está estranho porque "deveria" ser 11, mas já vamos chegar lá). Em JavaScript, as strings são internamente armazenadas em UTF-16 (na verdade é um pouco mais complicado que isso), e neste encoding os emojis usam mais de 1 byte (ou seja, o tamanho da string não é a quantidade de code points, e sim de bytes resultantes da conversão para este encoding).

Podemos ver o que ocorre se fizermos um loop para imprimir cada caractere e seu respectivo code point:

var texto = '💩 não são 🇦🇺';
for (var i = 0; i < texto.length; i++) {
    const char = texto[i];
    // imprime o caractere e seu respectivo code point
    console.log(`${char} -> ${char.codePointAt(0).toString(16)}`);
}

A saída será:

� -> d83d
� -> dca9
  -> 20
n -> 6e
ã -> e3
o -> 6f
  -> 20
s -> 73
a -> 61
̃ -> 303
o -> 6f
  -> 20
� -> d83c
� -> dde6
� -> d83c
� -> ddfa

Repare que ele imprimiu 16 linhas (o que explica porque em JavaScript o tamanho da string é 16). E aqui temos alguns detalhes interessantes.

O primeiro é que os emojis foram "quebrados" em vários code points. O 💩 foi quebrado em dois (U+D83D e U+DCA9), porque em UTF-16 isso é feito para todo code point acima de U+FFFF (isso é chamado de surrogate pair, o algoritmo para esta quebra está descrito aqui). E o emoji de bandeira é mais complicado, porque ele é formado por dois code points (no caso, são dois Regional Indicator Symbols, mais detalhes aqui), e cada um deles gera o respectivo surrogate pair.

Em Python ele considera os code points, como podemos ver neste código:

from unicodedata import name

texto = '💩 não são 🇦🇺'
for char in texto:
    print(f'{char} - U+{ord(char):04X} - {name(char, "")}')

Na saída podemos ver que ele imprime os code points, por isso os emojis não são quebrados em dois:

💩 - U+1F4A9 - PILE OF POO
  - U+0020 - SPACE
n - U+006E - LATIN SMALL LETTER N
ã - U+00E3 - LATIN SMALL LETTER A WITH TILDE
o - U+006F - LATIN SMALL LETTER O
  - U+0020 - SPACE
s - U+0073 - LATIN SMALL LETTER S
a - U+0061 - LATIN SMALL LETTER A
̃ - U+0303 - COMBINING TILDE
o - U+006F - LATIN SMALL LETTER O
  - U+0020 - SPACE
🇦 - U+1F1E6 - REGIONAL INDICATOR SYMBOL LETTER A
🇺 - U+1F1FA - REGIONAL INDICATOR SYMBOL LETTER U

E aqui podemos ver como o emoji de bandeira corresponde a dois code points. De forma bem resumida, existem Regional Indicador Symbols para cada letra do alfabeto, e quando juntamos duas dessas "letras", caso elas formem o respectivo código do país definido pela ISO 3166, o resultado é o emoji da bandeira deste país. Como "AU" é o código da Austrália, o resultado é o emoji da bandeira australiana.


Outro detalhe importante que dá pra ver acima é que o til da palavra "são" está separado da letra "a", enquanto na palavra "não" ele está junto. Isso acontece porque muitos caracteres possuem duas formas de serem representados. No caso do "ã", temos:

  1. forma composta - code point U+00E3 (LATIN SMALL LETTER A WITH TILDE) (ã)
  2. forma decomposta - como uma combinação de dois code points (nesta ordem):
    • a letra "a" (sem acento): U+0061 (LATIN SMALL LETTER A)
    • o til: U+0303 (COMBINING TILDE)

A primeira forma é chamada NFC (Canonical Composition), e a segunda, NFD (Canonical Decomposition). Ou seja, o "ã" está em NFC na palavra "não", e em NFD na palavra "são".

As duas formas são consideradas "canonicamente equivalentes", quando se trata de representar a letra "a" com til. Ou seja, são duas maneiras de se representar a mesma coisa. Somente ao olhar para a string, não dá pra saber em qual forma ela está, e para verificar isso precisa "escovar os bits" como fizemos. Para mais detalhes sobre normalização Unicode, veja aqui.

Enfim, isso explica porque em Python o tamanho da string é 13 e em JavaScript é 16. Um considera a quantidade de code points, o outro guarda a string internamente em UTF-16 e considera a quantidade de bytes resultantes. E um caractere pode ser formado por mais de um code point.

Por isso que a inversão não funciona

Os algoritmos usados acima para inverter a string não funcionam sempre porque eles simplesmente invertem os code points. Mas como já vimos, muitos caracteres/emojis são resultado da combinação de mais de um code point.

O emoji de bandeira, ao ser invertido, se torna "UA" (lembrando que não são as letras, são os respectivos Regional Indicator Symbols). E como este é o código da Ucrânia na ISO 3166, o resultado é a bandeira ucraniana.

Já o til da palavra "são" está separado da letra "a" (pois este está em NFD), então ao inverter os code points, eles acabam ficando nesta ordem: letra "o", til, letra "a", letra "s". E a regra do Unicode diz que o til deve ser aplicado ao caractere anterior, por isso que o resultado é "õas" em vez de "oãs".

Como resolver?

O Unicode define outro conceito, chamado de "Grapheme Cluster". É basicamente um conjunto de um ou mais code points que juntos significam "uma coisa só". É o caso do "ã" em NFD: os dois code points (letra "a" e til) combinados viram uma coisa só. É também o caso do emoji de bandeira: dois Regional Indicator Symbols juntos formam uma coisa só (a bandeira do respectivo país).

Se quisermos inverter a string considerando todos esses casos, devemos inverter os grapheme clusters. A maioria das linguagens não possui suporte nativo para tal, e nesses casos vc vai precisar de alguma biblioteca específica.

Longe de ser uma recomendação, pois só fiz testes básicos, mas para JavaScript encontrei esta, e em Python, esta. Para a string que usei acima, ambos funcionaram, mas tem que testar bastante porque existem casos mais complicados além dos que já citei. No fim, claro, depende também das strings que vc vai manipular.


De qualquer forma, fica o alerta de que mesmo operações simples como obter o tamanho de uma string podem ter mais de uma resposta, dependendo do que vc precisa: é a quantidade de code points, grapheme clusters ou bytes em um encoding específico? Para muitos casos (textos em ASCII, por exemplo) o resultado será o mesmo independente do método escolhido, mas nem sempre é o caso.

O mesmo vale para qualquer outra operação que envolva verificar ou manipular os caracteres de uma string (como por exemplo invertê-la, ver se é palíndromo, se possui determinado caractere, etc): dependendo do que vc precisa, escolher um ou outro método pode dar diferença no resultado.

3

Algumas considerações sobre ASCII:

Se estiver trabalhando com ASCII, certamente o problema não irá ocorrer pois é uma tabela com apenas 256 códigos e cada código ocupa sempre um byte (0-255). A composição da tabela é a seguinte:

  • 0-31 caracteres de controle.

  • 32-127 caracteres imprimíveis (+ - A a 9 / etc.).

  • 128-255 código estendidos.

Os códigos de 128-255 podem corresponder a símbolos diferentes dependendo da implementação. Mas continuam sendo um byte então, inverter uma string sempre vai funcionar. O problema aqui é que o À de uma tabela pode corresponder a um Ŕ da implementação em outra. Por exemplo:

ASCII Windows-1250 char(192) = Ŕ

ASCII ISO-8859-1 char(192) = À

Pessoalmente acho o Unicode muito bagunçado, no sentido de ter muita coisa inútil. Nem todas as fontes ttf possuem representação para todos os símbolos. Na realidade, algumas não possuem nem acentuação.

Acho os emoji legais para aquelas cartas enigmáticas que as crianças fazem. :D

O 🌞 + ⚄ foi 💤

Sobre o texto, me ocorre o que eu penso:

"O programador tem que escrever códigos legíveis e corretos mas ninguém ajuda o pobre coitado. Quem implementa os compiladores/interpretadores não pensa em facilitar a vida do programador que desenvolve sistemas para o mundo real.™. Este, por sua vez e com seu sangue de barata, acha que está tudo certo."

Eu penso que um caractere deixou de ser um byte há muito tempo. Uma string é uma sequência de caracteres e não de bytes. Vamos ignorar os emojis e se concentrar em um texto padrão. Supondo Josi e José.

  • Quem vem primeiro em ordem alfabética crescente? José < Josi

  • Como ficará a conversão para maiúsculas dos nomes? José => JOSÉ

  • Se procurar por Jose o programa vai encontrar? José = Jose

Não uso Ruby, mas acho que isso deveria ser normal para qualquer linguagem que se preze (teria que ver se COBOL já não é assim). Caso contrário, eu considero beta, acadêmica ou prova de conceito (sim, eu uso linguagens que ainda são beta ;-) ).

image

3

Complementando...

O ASCII original só tinha 128 caracteres. Aí perceberam que tinha mais 128 "sobrando", e que ainda sim caberia em um byte.

Vários tiveram essa ideia ao mesmo tempo e cada um criou seu próprio mapeamento - como vc disse, cada valor entre 128 e 255 poderia ser um caractere completamente diferente, dependendo do mapeamento.

Esses mapeamentos eram chamados de code pages, e foram criados vários (ISO-8859-1 é um dentre as dezenas de code pages existentes).

Fiz um post bem detalhado sobre isso aqui.


Quanto ao Unicode, concordo que ele deveria ser menos bagunçado. Há muitos blocos de miscelaneous, que agrupam caracteres que não tem relação nenhuma entre si, fora a zona que virou os emojis.

Já sobre o fato de nem todas as fontes suportarem todos os caracteres, eu fico meio dividido. Por um lado, entendo que o Unicode está mais preocupado em organizar a bagunça que é esse monte de caracteres usados no mundo todo, então cada um que se vire pra dar o suporte adequado a cada nova versão que sai. Por outro lado, talvez devesse ter uma preocupação a mais com esse aspecto (os "clientes diretos" do Unicode).

Mas acho que é algo difícil de gerenciar e resolver: imagine se além dos problemas que eles já tem, ainda tivessem que dar suporte para todas as fontes existentes. Enfim...


Quem vem primeiro em ordem alfabética crescente? José < Josi

Depende. Cada idioma possui uma regra diferente, alguns colocam as letras acentuadas depois. E também podem ter casos em que eu precise de uma ordem específica e customizada.

Inclusive, muitas linguagens/bibliotecas possuem opções para configurar estas regras. Mais detalhes aqui.

Como ficará a conversão para maiúsculas dos nomes? José => JOSÉ

Também depende, cada idioma possui suas regras. Em alguns a versão maiúscula ou minúscula muda dependendo da posição da letra na palavra. Outros não se limitam a ter maiúsculas e minúsculas e possuem uma terceira forma chamada titlecase. E em outros idiomas, como o japonês, sequer existem os conceitos de "letra maiúscula e minúscula".

Sei que nada disso se aplica ao seu exemplo, foi só pra ilustrar que quando estamos falando de caracteres, temos que definir bem o escopo. Se for "somente textos em português", por exemplo, aí as regras que vc descreveu se encaixam perfeitamente. Contexto é tudo.

Se procurar por Jose o programa vai encontrar? José = Jose

Também depende do contexto. Se eu procurar pela palavra "sabia", também deveria encontrar "sabiá" e "sábia"?

Se for nome próprio, piorou, pois existe Fabio sem acento por exemplo, então neste caso seria melhor diferenciar de Fábio pra não trazer muitos falsos positivos.

Enfim, não dá pra cravar regras absolutas nem pra essas coisas que parecem "óbvias". Sempre depende do contexto.

Hoje a maioria das linguagens mainstream possui um suporte adequado ao Unicode e também permite configurar os detalhes que mencionei acima.

Quanto ao código Ruby que converte a string para bytes, vale lembrar que o resultado depende do encoding usado. Pelo jeito o Ruby usa UTF-8 por default. Mas veja como dá diferença se usar outro (código em Python):

s = "José"
 
for enc in ("utf-8", "utf-16", "latin1", "utf-32"):
    print(f"{enc} - {','.join(map(str, s.encode(enc)))}")

A saída é:

utf-8 - 74,111,115,195,169
utf-16 - 255,254,74,0,111,0,115,0,233,0
latin1 - 74,111,115,233
utf-32 - 255,254,0,0,74,0,0,0,111,0,0,0,115,0,0,0,233,0,0,0

Por fim, concordo com e reforço a sua afirmação de que a ideia de "1 caractere = 1 byte" está ultrapassada há muito tempo (e me espanta como a esmagadora maioria dos cursos ignora esse fato, é só ver a quantidade de devs que chega ao mercado sem ter a menor noção disso).

2

Desculpe. Faltou contexto. Posso alegar que estava indignado com a minha internet que passou, praticamente todo o dia assim: https://postimg.cc/G8s5J4Zz

Sim, estava falando de Português. Cada idioma terá suas regras. Os nomes são de pessoas e é para coisa pequena. Se for um cadastro, provavelmente vai estará em uma tabela e o SGBD terá os recursos para tal, tipo CHARSET UTF8 COLLATION UNICODE_CI_AI (case-insensitive, accent-insensitive) e variantes.

Ruby e diversas outras linguagens adotam UTF8 como padrão. Mas algumas ainda retornam o tamanho de uma string em bytes (é necessário um trabalho extra para as coisas funcionarem corretamente).

Se faltou contexto agora é que estou cansado. :D

Off topic

Depois olho o teu blog. Mas vi que tem um tópico sobre Data/Horas x Duração e eu citei anteriormente que as linguagens devem facilitar a vida do programador. Unidades é outro ponto que acho importante em linguagens de programação. Exemplo em Red (linguagem beta e que ninguém usa) para comparar com os exemplos do teu blog

ponto: [8:00 17:00 8:15 18:30 9:30 18:30]
total: 0:00
foreach [entrada saida] ponto [
   total: total + saida - entrada
]
print ["total: " total]

>> total: 28:15:00
2
2
2
1