Unicode, Runes e Encodings de Texto
Objective
Uma string do .NET é uma sequência de unidades de código UTF-16, arquivos e redes
falam em bytes, e o que uma pessoa chama de "um caractere" pode ser vários code
points. A maioria dos bugs de texto mora nas lacunas entre esses três níveis: um
Length que não bate com o que aparece na tela, um emoji partido ao meio, um
arquivo que mostra é em vez de é, um token que quebra dentro de uma URL. Este
conceito dá nome aos níveis (char, Rune, grapheme cluster), mostra como os
encodings transformam texto em bytes e o que acontece com dado inválido, cobre o
BOM e termina nas codificações de binário para texto do dia a dia: Base64,
Base64Url e hex.
Use Cases
- Truncar, preencher ou dividir texto de usuário sem cortar um emoji ou uma letra acentuada ao meio.
- Ler e gravar arquivos e corpos HTTP com o encoding certo, sabendo quando um BOM é adicionado.
- Validar bytes não confiáveis como UTF-8 antes de tratá-los como texto.
- Colocar dado binário (um token, um hash, um id) numa URL, num header ou num log.
Deep Dive
Três níveis: char, Rune, grapheme
string.Length conta unidades de código UTF-16. Code points acima de U+FFFF
precisam de duas delas, um par substituto (surrogate pair). Um Rune é um scalar
value Unicode (qualquer code point que não seja surrogate), e é o que você quer ao
iterar "caracteres" no sentido de code point:
csharpstring s = "aé\U0001F600"; // a, e agudo, rosto sorrindo
Console.WriteLine($"Length={s.Length} UTF8={Encoding.UTF8.GetByteCount(s)} UTF16={Encoding.Unicode.GetByteCount(s)} UTF32={Encoding.UTF32.GetByteCount(s)}");
// Length=4 UTF8=7 UTF16=8 UTF32=12
Console.WriteLine(char.IsHighSurrogate(s[2]) + " " + char.IsLowSurrogate(s[3])); // True True
foreach (var r in s.EnumerateRunes())
Console.WriteLine($"U+{r.Value:X4} utf8={r.Utf8SequenceLength} utf16={r.Utf16SequenceLength}");
// U+0061 utf8=1 utf16=1
// U+00E9 utf8=2 utf16=1
// U+1F600 utf8=4 utf16=2
new Rune(0xD800); // ArgumentOutOfRangeException: um surrogate isolado não é scalar valueOs mesmos três caracteres ocupam 7 bytes em UTF-8, 8 em UTF-16 e 12 em UTF-32. Os
construtores de Rune validam a entrada, então um Rune é sempre um scalar bem
formado e pode ir para APIs como Rune.IsLetter ou Rune.ToUpperInvariant. Chamar
char.ToUpperInvariant em cada metade de um surrogate pair separadamente não
funciona, porque cada metade sozinha não carrega informação de letra.
Uma string costuma ser UTF-16 bem formado, mas isso não é garantido:
"x\uD83Dy" (um high surrogate isolado) ou um Substring que corta um par ao meio
compilam e rodam normalmente. Codificá-la mostra o que o runtime faz com isso, e a
próxima seção volta ao assunto.
Mesmo assim, runes não são o que o leitor vê como um símbolo. Isso é um grapheme
cluster, que o .NET chama de text element, e o StringInfo os enumera:
csharpstring accent = "é"; // e + acento agudo combinante
string flag = "\U0001F1F5\U0001F1F9"; // dois regional indicators
string family = "\U0001F468\U0001F469\U0001F467"; // emoji de família com ZWJ
foreach (var t in new[] { accent, flag, family })
Console.WriteLine($"Length={t.Length} runes={t.EnumerateRunes().Count()} graphemes={new StringInfo(t).LengthInTextElements}");
// Length=2 runes=2 graphemes=1
// Length=4 runes=2 graphemes=1
// Length=8 runes=5 graphemes=1
Console.WriteLine(accent == "é"); // False: dois code points contra um
Console.WriteLine(accent.Normalize() == "é"); // True: compõe para FormCUse StringInfo quando a unidade que importa é "o que o usuário vê": movimento do
cursor, prévia dos "primeiros 10 caracteres", limite de tamanho visível. A
documentação da Microsoft registra que o StringInfo tinha um bug antes do .NET 5,
um motivo a mais para não escrever lógica de grafemas à mão. Para comparar textos
que parecem iguais, normalize antes (Normalize, NormalizationForm); o conceito
anterior de strings mostra quando uma comparação com cultura faz isso por você.
Encodings, bytes inválidos e o BOM
Encoding.UTF8 é o padrão certo para arquivos, HTTP e APIs, e é o que o .NET usa na
maior parte do I/O de texto. Decodificar com o encoding errado não lança exceção,
produz lixo, e codificar texto que não cabe substitui em silêncio:
csharpbyte[] u8 = Encoding.UTF8.GetBytes("café");
Console.WriteLine(Encoding.Latin1.GetString(u8)); // café (o mojibake clássico)
Console.WriteLine(Encoding.ASCII.GetString(u8)); // caf??
Console.WriteLine(Encoding.ASCII.GetBytes("café")[3]); // 63, o '?' de substituição
string broken = "x\uD83Dy";
Console.WriteLine(Convert.ToHexString(Encoding.UTF8.GetBytes(broken))); // 78EFBFBD79
new UTF8Encoding(false, throwOnInvalidBytes: true).GetBytes(broken); // EncoderFallbackExceptionEF BF BD é o U+FFFD, o caractere de substituição. Segundo a documentação, os
encodings embutidos substituem entrada malformada por padrão e podem ser
configurados para lançar exceção, que é a configuração para código sensível a
segurança, onde alterar dado em silêncio é inaceitável. A mesma documentação avisa
que bytes vindos da rede podem ser UTF-8 malformado de propósito, então valide
(decodifique com um encoding que lança, ou chame System.Text.Unicode.Utf8.IsValid
sobre um span) antes de confiar neles.
Um BOM (byte order mark, EF BB BF em UTF-8) são alguns bytes no início de um
arquivo que anunciam o encoding. Ele sinaliza a ordem dos bytes no UTF-16 e é
opcional, e muitas vezes indesejado, no UTF-8. No .NET o singleton Encoding.UTF8
tem um preâmbulo de 3 bytes que as APIs de arquivo gravam, enquanto
new UTF8Encoding(false) não tem nenhum:
csharpConsole.WriteLine(Convert.ToHexString(new UTF8Encoding(true).GetPreamble())); // EFBBBF
File.WriteAllText(path, "hi"); // 2 bytes
File.WriteAllText(path, "hi", Encoding.UTF8); // 5 bytes: BOM adicionado
File.WriteAllText(path, "hi", Encoding.Unicode); // FFFE68006900: UTF-16 LE com BOM
File.ReadAllText(path); // "hi": o leitor detecta o BOMLeitores como File.ReadAllText e StreamReader detectam o BOM e trocam de
encoding, por isso um arquivo UTF-16 volta correto sem você dizer nada. Para gravar
UTF-8 explicitamente sem BOM, use new UTF8Encoding(false), cujo GetPreamble() é
vazio (conferimos o tamanho: 0).
Code pages legadas como Windows-1252 não vêm habilitadas por padrão no .NET
moderno. Encoding.GetEncoding(1252) lança NotSupportedException até você chamar
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance), depois do que
funciona. Encoding.Latin1 (ISO-8859-1) já vem embutido e mapeia € para ?, porque
esse símbolo não existe no Latin-1, enquanto o Windows-1252 o mapeia para 0x80.
Trate code pages como ferramenta de compatibilidade com arquivos e parceiros
antigos, não como escolha para dados novos.
Base64, Base64Url e hex
Dado binário em forma de texto usa três codificações. O Base64 padrão usa +, / e
o padding =, que são incômodos em URLs. O Base64Url (novo no .NET 9, em
System.Buffers.Text) usa - e _ e omite o padding:
csharpbyte[] data = [0xFB, 0xFF, 0xFE, 0x3E, 0x3F];
Console.WriteLine(Convert.ToBase64String(data)); // +//+Pj8=
Console.WriteLine(Base64Url.EncodeToString(data)); // -__-Pj8
Console.WriteLine(Convert.ToHexString(Base64Url.DecodeFromChars("-__-Pj8"))); // FBFFFE3E3F
Console.WriteLine(Convert.ToBase64String([1]) + " " + Base64Url.EncodeToString([1])); // AQ== AQ
Console.WriteLine(Convert.ToHexString(data) + " " + Convert.ToHexStringLower(data)); // FBFFFE3E3F fbfffe3e3fA documentação afirma que o alfabeto do Base64Url difere do Base64 só por trocar +
e / por - e _. Convert.ToHexString escreve em maiúsculas e
Convert.ToHexStringLower (.NET 9) em minúsculas, o que poupa uma chamada a
ToLower e sua alocação quando um protocolo, como muitas representações de hash,
espera minúsculas. Base64 e hex são codificações, não criptografia: qualquer um as
reverte.
Trade-offs
- Indexar por
charé rápido e errado para certos textos.s[i]eLengthsão baratos e corretos para ASCII e a maior parte do BMP, e silenciosamente errados para emoji e marcas combinantes. UseRuneouStringInfoonde o usuário pode digitar qualquer coisa, e aceite o custo. - Enumerar runes não é enumerar grafemas.
EnumerateRunesresolve só os surrogate pairs;"é"continua sendo dois runes. Para a semântica de "caractere visível" você precisa doStringInfo, que aloca e é mais lento. - Substituir versus lançar exceção. O
U+FFFDsilencioso mantém o pipeline rodando e esconde corrupção. UmUTF8Encodingque lança expõe a entrada ruim ao preço de ter que tratar a exceção. Escolha por fronteira: leniente para exibição, estrito para identificadores, assinaturas e tudo que é comparado byte a byte. - Um BOM ajuda ferramentas e atrapalha outras. Ele torna a detecção de UTF-8 confiável em alguns editores e planilhas, e pode confundir consumidores que não o esperam, como linhas shebang. O padrão é não usar BOM, a menos que um consumidor o exija.
Base64Urlcomeça no .NET 9. A documentação da API também lista um pacoteMicrosoft.Bcl.Memorypara alvos mais antigos. Reimplementar à mão a troca de+e/mais o padding é fácil de errar de forma sutil.