Quando um espaço não é um espaço - ou é, mas de outro tipo (ou, Unicode e suas bizarrices 4)
Este post é mais um da série de "bizarrices do Unicode" que estou escrevendo. Os anteriores são:
Era uma vez um programador que precisava fazer um programa bem simples: receber uma string contendo um valor monetário formatado, e extrair a moeda e o valor. Ele tentou o seguinte (todos os exemplos estão em Python):
texto = 'R$ 42,00'
moeda, valor = texto.split(' ')
Parece simples, não? split
para separar por espaço, e em seguida pegar os valores. Mas para a surpresa do programador, o código deu erro:
Traceback (most recent call last):
File "/home/dev_perdido/script.py", line 2, in <module>
moeda, valor = texto.split(' ')
^^^^^^^^^^^^
ValueError: not enough values to unpack (expected 2, got 1)
Atenção: se vc for testar os códigos, sugiro fazer o copy-paste direto dos respectivos trechos, pois se vc usar a tecla de espaço do seu teclado, não ocorrerá o erro. Mais abaixo entenderemos o motivo.
Basicamente, o erro indica que o resultado do split
não tem dois valores, por isso ele não conseguiu atribuí-los nas duas variáveis (moeda
e valor
). Confuso, o programador resolveu verificar o resultado do split
, imprimindo o tamanho da lista retornada e o seu conteúdo:
texto = 'R$ 42,00'
v = texto.split(' ')
print(len(v)) # imprime o tamanho da lista
print(v) # imprime a própria lista
O resultado foi:
1
['R$\xa042,00']
Ou seja, a lista retornada por split
contém apenas um elemento (e não dois, como ele esperava). E ao imprimir a própria lista, ele reparou que o único valor era a string 'R$\xa042,00'
. Ao ver esse \xa0
em vez de um espaço, ele ficou mais confuso ainda e pediu ajuda a um programador mais experiente, que respondeu:
– Ah, é um NO-BREAK SPACE.
– Que @#@$% é essa? - perguntou o jovem programador
Um espaço que não é um espaço (ou é outro "tipo" de espaço)
O NO-BREAK SPACE (que também é chamado de non-breaking space) é um caractere diferente do SPACE – o "espaço tradicional" que é produzido quando usamos a tecla de espaço do teclado (por isso que o erro acima só ocorre se vc copiar e colar o código, pois se usar o teclado, não será produzido o NO-BREAK SPACE). Apesar de ser renderizado da mesma forma (ou seja, "a olho nu" não dá pra ver a diferença), é outro caractere.
Para ver a diferença, podemos fazer um programa que verifica cada caractere da string:
from unicodedata import name
texto = 'R$ 42,00'
for c in texto:
print(f'{c} - {ord(c):04X} - {name(c)}')
Novamente, se for testar este código, faça copy-paste do trecho acima. Se vc usar a tecla de espaço do teclado, ele não vai produzir o NO-BREAK SPACE.
O resultado é:
R - 0052 - LATIN CAPITAL LETTER R
$ - 0024 - DOLLAR SIGN
- 00A0 - NO-BREAK SPACE
4 - 0034 - DIGIT FOUR
2 - 0032 - DIGIT TWO
, - 002C - COMMA
0 - 0030 - DIGIT ZERO
0 - 0030 - DIGIT ZERO
E como podem ver, o terceiro caractere é um NO-BREAK SPACE, cujo code point1 é U+00A0. Por isso que ele foi mostrado como \xa0
: o \xhh
é uma sequência de escape na qual hh
são os dígitos hexadecimais do caractere em questão (no caso, a0
). Curiosamente, se imprimirmos diretamente print(texto)
, ele é mostrado como um espaço, em vez do código hexadecimal (devido a detalhes internos da linguagem quanto à forma na qual listas e strings são impressas).
Enfim, o problema é esse. Ao fazer split(' ')
, estou considerando apenas o espaço "normal" (ou "espaço ASCII", por assim dizer), cujo code point é U+0020. Por isso a quebra não é feita e no final a lista só tem um elemento.
E não pense que é um caso raro. Para formatar valores monetários, muitas API's usam o NO-BREAK SPACE, e em muitas páginas HTML ele também é usado. A diferença dele para o espaço "normal" é que o NO-BREAK SPACE impede a quebra de linha caso não haja espaço horizontal.
Por exemplo, dado um HTML com duas div
's, uma com o espaço normal e outra com o NO-BREAK SPACE:
<div>
O valor é R$ 42,00
</div>
<br>
<div>
O valor é R$ 42,00
</div>
é um "atalho" (também chamado de "HTML Entity" ou "Character Reference") para o NO-BREAK SPACE. A lista completa pode ser encontrada aqui.
E este CSS:
div {
font: 15px "Arial";
border: 1px solid red;
width: 90px;
}
O resultado será:
Repare que com espaço "normal", a quebra de linha é feita logo depois do R$
. Mas com NO-BREAK SPACE, o R$
não é separado do 42,00
, então a quebra de linha é feita antes. Esta é uma forma de garantir que o valor não ficará separado do símbolo da moeda. Ou seja, a escolha pelo tipo de espaço ali não foi aleatório, e sim algo bem pensado com um propósito específico.
E a solução?
O programador ficou tão encantado com a explicação que quase esqueceu de perguntar como resolver no código. "Ah, é", respondeu o programador experiente.
Uma alternativa é fazer o split
com o NO-BREAK SPACE em vez do espaço "normal". Sabendo que o code point dele é U+00A0, basta usar esse valor:
texto = 'R$ 42,00'
# O prefixo 0x é para que "a0" seja interpretado como hexadecimal,
# e chr retorna o caractere que corresponde a esse valor
moeda, valor = texto.split(chr(0xa0))
print(f'Moeda={moeda}, Valor={valor}') # Moeda=R$, Valor=42,00
"Espera", lembrou o programador, "tem vezes que pode vir com espaço normal". Sem problemas, nesse caso podemos usar uma regex:
import re
spaces = re.compile(r'\s')
# com NO-BREAK SPACE
texto = 'R$ 42,00'
moeda, valor = spaces.split(texto)
print(f'Moeda={moeda}, Valor={valor}') # Moeda=R$, Valor=42,00
# com espaço "normal"
texto = 'R$ 42,00'
moeda, valor = spaces.split(texto)
print(f'Moeda={moeda}, Valor={valor}') # Moeda=R$, Valor=42,00
Obs: lembrando novamente, o NO-BREAK SPACE é renderizado como um espaço "normal", então só olhando o código acima não dá pra ver a diferença. Se quiser verificar, sugiro copiar as duas strings acima e usar o código anterior que verifica cada um dos caracteres (o que usa o módulo
unicodedata
).
No caso, o atalho \s
pega qualquer espaço, tanto o "normal" quanto o NO-BREAK SPACE. Mas vale lembrar que ele também pega quebras de linha, TAB e muitos outros caracteres (escrevi uma explicação bem detalhada aqui, e vale notar que a lista completa pode variar conforme a linguagem ou a engine/lib de regex sendo usada). Então se quiser apenas os dois tipos de espaço em questão, e nenhum outro caractere, pode mudar para re.compile(f' |{chr(0xa0)}')
por exemplo.
Enfim, o Unicode tem várias dessas pegadinhas/esquisitices (ou, pra usar um nome bonito, "idiossincrasias"). Existe uma categoria específica chamada "Separator, Space" com diferentes caracteres de espaço. Explicar um a um já foge do escopo deste texto, mas é importante pelo menos saber que eles existem, pra não ser pego de surpresa quando surgir um na sua frente. Alguns parecem ter um uso mais restrito (muitos ali eu nunca me deparei, por exemplo), mas no caso do NO-BREAK SPACE, como já dito, não é tão raro assim.
E como curiosidade, isso não ocorre somente com espaços. Existem vários hífens diferentes (inclusive um non-breaking hyphen, bem similar ao NO-BREAK SPACE), vários tipos de aspas, e por aí vai. E eles podem causar problemas similares, de vc achar que é um caractere mas na verdade é outro parecido. E que pra piorar, pode ser renderizado da mesma forma e vc só vai perceber a diferença se "escovar os bits" e verificar cada caractere.