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
- existe ainda o conceito de code unit, que é "a menor combinação de bits que representa uma unidade de texto codificado para processamento ou intercâmbio". Por exemplo, o UTF-8 usa code units de 8 bits cada (e cada code point pode usar de 1 a 4 code units), enquanto o UTF-16 usa code units de 16 bits cada
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 code unit (ou seja, o tamanho da string não é a quantidade de code points, e sim de code units 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:
- forma composta - code point U+00E3 (LATIN SMALL LETTER A WITH TILDE) (ã)
- 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 code units 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.