Recentemente eu estava revisitando strings e Unicode em Go e encontrei uma pergunta pequena que, à primeira vista, parecia levar poucos segundos para responder:
package main
import "fmt"
func main() {
s1 := "✍️"
s2 := "👨💻"
s3 := "😆"
fmt.Println(len(s1), len(s2), len(s3))
}
O que esse programa imprime?
A maioria dos desenvolvedores Go conhece a regra básica: len aplicado a uma string retorna o tamanho dela em bytes. Também sabemos que UTF-8 usa de um a quatro bytes para codificar um ponto de código Unicode, e muitos emojis comuns ocupam quatro bytes.
Então, se você passar os olhos pelo código rápido demais, 4 4 4 parece uma resposta razoável.
A saída real é:
6 11 4
À primeira vista, parece que UTF-8 está fazendo alguma coisa estranha. Não está.
Um único ponto de código Unicode continua ocupando no máximo quatro bytes em UTF-8. O detalhe está em outro lugar: um emoji exibido como uma única coisa na tela não precisa ser formado por um único ponto de código Unicode.
Por que len() em Go retorna 6, 11 e 4
Uma string em Go é uma sequência de bytes. Ela pode conter bytes arbitrários, embora texto Unicode escrito diretamente no código-fonte Go normalmente esteja codificado em UTF-8.
Por isso:
len(s)
retorna a quantidade de bytes, não a quantidade de pontos de código Unicode e nem a quantidade de caracteres que o usuário enxerga.
O caso mais simples do exemplo é:
😆
Esse emoji corresponde ao ponto de código Unicode U+1F606.
É um único ponto de código, codificado em quatro bytes em UTF-8:
fmt.Println(len("😆")) // 4
Provavelmente é daí que vem a regra útil, mas incompleta, de que “um emoji ocupa quatro bytes”.
Agora considere:
✍️
Visualmente é um único emoji, mas internamente existem dois pontos de código Unicode:
U+270D WRITING HAND
U+FE0F VARIATION SELECTOR-16
U+FE0F é um seletor de variação. Nessa sequência, ele pede ao renderizador que use a apresentação em estilo emoji para o caractere anterior.
Os dois pontos de código ocupam três bytes cada em UTF-8:
U+270D 3 bytes
U+FE0F 3 bytes
total 6 bytes
Logo:
fmt.Println(len("✍️")) // 6
Com o emoji de profissional de tecnologia, a composição vai um pouco além:
👨💻
Ele é formado por três pontos de código:
U+1F468 MAN
U+200D ZERO WIDTH JOINER
U+1F4BB PERSONAL COMPUTER
U+200D é o Zero Width Joiner, normalmente abreviado como ZWJ. É um caractere Unicode invisível que permite juntar elementos vizinhos em uma única sequência visual.
Sem o ZWJ:
👨💻
Com o ZWJ:
👨💻
Os tamanhos em UTF-8 são:
U+1F468 4 bytes
U+200D 3 bytes
U+1F4BB 4 bytes
total 11 bytes
Agora o resultado original deixa de parecer tão estranho:
✍️ 6 bytes
👨💻 11 bytes
😆 4 bytes
Não existe aqui um ponto de código UTF-8 de onze bytes. Existem três pontos de código separados cujas representações em UTF-8 somam onze bytes, e o renderizador mostra a sequência inteira como um único emoji.
Uma runa é um ponto de código Unicode, não necessariamente um caractere visível
A próxima ideia natural em Go é parar de contar bytes e passar a contar runas.
Em Go, rune é um alias para int32 e é usado para representar pontos de código Unicode.
Por exemplo:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
fmt.Println(utf8.RuneCountInString("✍️"))
fmt.Println(utf8.RuneCountInString("👨💻"))
fmt.Println(utf8.RuneCountInString("😆"))
}
A saída é:
2
3
1
Agora podemos descrever as mesmas strings de três maneiras diferentes:
| Texto | Bytes UTF-8 | Runas | Emojis visíveis |
|---|---|---|---|
✍️ | 6 | 2 | 1 |
👨💻 | 11 | 3 | 1 |
😆 | 4 | 1 | 1 |
Esse é o detalhe fácil de perder mesmo quando você já sabe exatamente o que len faz em Go.
Uma runa não é necessariamente um caractere no sentido em que o usuário entende a palavra.
Um loop for range deixa a estrutura interna da string bem explícita:
package main
import "fmt"
func main() {
s := "👨💻"
for i, r := range s {
fmt.Printf("byte index: %d, rune: %U\n", i, r)
}
}
Saída:
byte index: 0, rune: U+1F468
byte index: 4, rune: U+200D
byte index: 7, rune: U+1F4BB
Repare que i é um deslocamento em bytes, não o índice da runa.
A primeira runa começa no byte 0 e ocupa quatro bytes. Por isso o ZWJ começa no byte 4. Como ele ocupa três bytes, o notebook começa no byte 7.
A string completa ocupa onze bytes.
Clusters de grafemas: contando o que o usuário vê
Unicode tem outro conceito importante para esse problema: o cluster de grafemas.
Simplificando um pouco, um cluster de grafemas é uma sequência que o usuário percebe como um único caractere. Ele pode conter um ponto de código ou vários.
Nos nossos exemplos:
✍️ 2 runas, 1 cluster de grafemas
👨💻 3 runas, 1 cluster de grafemas
😆 1 runa, 1 cluster de grafemas
A biblioteca padrão de Go oferece operações com bytes e com runas UTF-8, mas não traz um contador genérico de clusters de grafemas.
Se uma aplicação realmente precisa contar caracteres da forma como o usuário os percebe, é possível usar uma biblioteca de segmentação Unicode. Uma opção em Go é github.com/rivo/uniseg:
package main
import (
"fmt"
"github.com/rivo/uniseg"
)
func main() {
fmt.Println(uniseg.GraphemeClusterCount("✍️"))
fmt.Println(uniseg.GraphemeClusterCount("👨💻"))
fmt.Println(uniseg.GraphemeClusterCount("😆"))
}
O resultado é:
1
1
1
Essa é uma pergunta diferente das que len e utf8.RuneCountInString respondem. Por isso o resultado também é diferente.
Com modificadores, os emojis ficam ainda mais interessantes
Um bom exemplo de até onde essa composição pode chegar é:
👩🏿💻
Na tela, é um único emoji: uma tecnóloga com pele escura.
Internamente existem quatro pontos de código:
U+1F469 WOMAN
U+1F3FF EMOJI MODIFIER FITZPATRICK TYPE-6
U+200D ZERO WIDTH JOINER
U+1F4BB PERSONAL COMPUTER
Em UTF-8 eles ocupam:
U+1F469 4 bytes
U+1F3FF 4 bytes
U+200D 3 bytes
U+1F4BB 4 bytes
total 15 bytes
Em Go dá para enxergar isso diretamente:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "👩🏿💻"
fmt.Println(len(s))
fmt.Println(utf8.RuneCountInString(s))
for i, r := range s {
fmt.Printf("byte index: %d, rune: %U\n", i, r)
}
}
A saída é:
15
4
byte index: 0, rune: U+1F469
byte index: 4, rune: U+1F3FF
byte index: 8, rune: U+200D
byte index: 11, rune: U+1F4BB
Um único elemento visual. Quatro runas. Quinze bytes.
Essa sequência também ajuda a entender por que editar emojis em um editor de texto às vezes produz resultados inesperados.
Visualmente, 👩🏿💻 parece um único caractere. Por baixo, porém, existem vários pontos de código. Se você colocar o cursor no fim da string e começar a apagar da direita para a esquerda, os estados intermediários podem variar conforme o editor, a posição do cursor e a unidade Unicode que ele considera como uma operação de remoção.
Com o cursor no fim, apagar da direita para a esquerda normalmente significa usar Backspace. Uma sequência possível é:
"👩🏿💻" -> um Backspace -> "👩🏿" -> "👩🏿" -> "👩�" -> "👩" -> ""
Também é possível observar uma sequência como esta:
"👩🏿💻" -> "👩🏿💻" -> "👩�💻" -> "👩💻" -> "💻" -> ""
No primeiro caso, o notebook desaparece antes do ZWJ e do modificador de tom de pele. Em "👩🏿" ainda existe um ZWJ invisível no final da string, então visualmente esse passo pode parecer idêntico a "👩🏿", apesar de o conteúdo ser diferente.
No segundo caso, o ZWJ some primeiro. Sem o joiner, o renderizador deixa de combinar a mulher e o notebook em um único emoji, e o resultado passa a ser 👩🏿💻.
O � merece uma observação separada. Ele é o U+FFFD REPLACEMENT CHARACTER e não fazia parte do emoji original. Esse caractere pode aparecer quando o editor ou a etapa de decodificação encontra uma sequência de unidades de código ou bytes malformada ou incompleta, por exemplo metade de um par substituto ou uma sequência multibyte truncada. Um editor mais consciente de Unicode pode remover um ponto de código inteiro ou até o cluster de grafemas completo, portanto essas etapas exatas não são garantidas entre editores diferentes.
Esse é justamente o ponto: aquilo que parece um único caractere pode conter vários pontos de código, modificadores e joiners invisíveis. O resultado de uma tecla de remoção depende da fronteira textual que o editor escolhe como unidade de edição.
Para comparar: como isso aparece em C#
Eu também trabalho com C#, e ele serve como uma comparação interessante porque strings em .NET expõem uma unidade diferente por padrão.
Usando os mesmos valores:
string s1 = "✍️";
string s2 = "👨💻";
string s3 = "😆";
Console.WriteLine(s1.Length);
Console.WriteLine(s2.Length);
Console.WriteLine(s3.Length);
A saída é:
2
5
2
Os números são diferentes dos de Go, mas C# também não está contando caracteres visíveis.
string.Length em C# retorna a quantidade de unidades de código UTF-16. Um char em C# representa uma unidade de código UTF-16 de 16 bits.
Pontos de código no Plano Multilíngue Básico (BMP) cabem em uma unidade de código UTF-16. Pontos de código acima de U+FFFF precisam de um par substituto, ou seja, dois valores char.
Para a sequência da mão:
U+270D 1 char
U+FE0F 1 char
total 2 char
Para o profissional de tecnologia:
U+1F468 2 char
U+200D 1 char
U+1F4BB 2 char
total 5 char
E até 😆, apesar de ser um único ponto de código Unicode, precisa de duas unidades de código UTF-16:
U+1F606 2 char
É por isso que o código C# abaixo não compila:
char c = '😆';
😆 precisa de um par substituto, enquanto um único char armazena exatamente uma unidade de código UTF-16.
O .NET moderno também tem System.Text.Rune, que é muito mais próximo em propósito do rune de Go:
using System.Text;
Rune r = new Rune(0x1F606);
Console.WriteLine(r); // 😆
Mas a mesma distinção de Unicode continua existindo.
Um Rune representa um único valor escalar Unicode. Ele não consegue representar sozinho uma sequência inteira formada por vários pontos de código, como:
✍️
👨💻
👩🏿💻
Trocar de linguagem não elimina esse detalhe de Unicode. O que muda, principalmente, é qual unidade de texto a API padrão de strings expõe ao desenvolvedor.
O que isso muda ao trabalhar com strings em Go
Em bastante código de backend, len(s) é exatamente o que você quer.
Se você está validando tamanho de payload, alocando buffers, trabalhando com protocolos ou lidando com representação de dados, bytes são a unidade correta.
Se você precisa de pontos de código Unicode, utf8.RuneCountInString, []rune(s) e for range são as ferramentas adequadas.
A ambiguidade começa quando o requisito diz algo como:
O nome de usuário deve ter no máximo 20 caracteres.
O que “20 caracteres” quer dizer nesse caso?
Vinte bytes fazem ASCII e emojis se comportarem de formas muito diferentes.
Vinte runas costuma ficar mais perto da intenção, mas 👩🏿💻 ainda conta como quatro runas, embora o usuário veja um único emoji.
Se o limite deve corresponder ao número de caracteres percebidos pelo usuário, a unidade relevante é o cluster de grafemas.
A mesma diferença importa ao truncar strings e ao implementar edição de texto.
Cortar uma string em um deslocamento de byte arbitrário pode dividir uma sequência UTF-8 no meio e produzir UTF-8 inválido. Converter para []rune evita esse problema específico, mas ainda é possível cortar um cluster de grafemas entre duas runas.
É assim que um código pode manter a mulher e remover o notebook, eliminar o modificador de tom de pele ou deixar um joiner invisível para trás.
Na prática, a palavra “caractere” pode esconder pelo menos três perguntas diferentes:
Quantos bytes existem aqui?
Quantos pontos de código Unicode existem aqui?
Quantos caracteres o usuário percebe?
Em Go, len responde à primeira.
Runas respondem à segunda.
Segmentação por clusters de grafemas responde à terceira.
O resultado original 6 11 4 não é uma exceção à regra de um a quatro bytes por ponto de código do UTF-8.
UTF-8 nunca quebrou essa regra.
Nós só estávamos contando uma coisa diferente daquela que apareceu na tela.