String Normalization com Span no C# e .NET 10
A string normalization no .NET 10 ganhou um novo conjunto de APIs que trabalham diretamente com Span<char> e ReadOnlySpan<char>, sem exigir uma string na entrada nem na saída. Com a chegada do .NET 10, em novembro de 2025, você passa a normalizar texto Unicode reduzindo alocações desnecessárias em aplicações de alto desempenho.
Neste artigo, você vai entender o que é normalização Unicode, por que as APIs anteriores geravam alocações extras e como usar GetNormalizedLength, IsNormalized e TryNormalize na prática. Além disso, vai ver como escolher o buffer de destino e quais cuidados tomar com essas novas extensões.
O que é string normalization
Alguns caracteres Unicode têm mais de uma representação binária equivalente. Por exemplo, a letra é pode aparecer como um único code point, o U+00E9, ou como a letra e seguida do acento agudo combinante U+0301.
Visualmente, as duas formas são idênticas. Contudo, uma comparação ordinal enxerga sequências diferentes de valores e conclui que os textos não são iguais.
A string normalization resolve esse problema. Segundo o guia de normalização de strings da Microsoft, o padrão Unicode define um processo que devolve uma única representação binária para qualquer uma das representações equivalentes de um caractere.
Dessa forma, depois de normalizar os dois lados, você pode comparar os dados por equivalência, sem depender de como cada sistema gravou o texto.
As quatro formas de normalização
O padrão Unicode trabalha com dois tipos de equivalência. A equivalência canônica considera que duas sequências têm a mesma aparência e o mesmo significado, enquanto a equivalência de compatibilidade aceita aparências distintas com significado parecido.
A partir dessas equivalências, o Unicode define quatro formas, todas disponíveis no enum NormalizationForm do .NET:
| Forma | Enum no .NET | Processo |
|---|---|---|
| NFC | NormalizationForm.FormC |
Decomposição canônica seguida de composição canônica |
| NFD | NormalizationForm.FormD |
Decomposição canônica |
| NFKC | NormalizationForm.FormKC |
Decomposição de compatibilidade seguida de composição canônica |
| NFKD | NormalizationForm.FormKD |
Decomposição de compatibilidade |
Na prática, a forma C é a mais usada e também é o padrão das novas APIs. Já as formas K podem perder informação de formatação, como a ligadura fi, que vira as letras f e i.
Por que a string normalization importa em comparações
Imagine um sistema de busca que recebe texto de navegadores, aplicativos móveis e arquivos importados. Cada origem pode gravar café em uma forma diferente, e uma comparação ordinal trataria os dois valores como palavras distintas.
A documentação do enum NormalizationForm reforça esse ponto. Antes de fazer comparações ordinais, a aplicação precisa normalizar as strings para que sequências equivalentes fiquem com os mesmos valores.
Por isso, a string normalization aparece em cenários como chaves de cache, deduplicação de cadastros, índices de busca e validação de nomes de usuário. Em todos eles, o mesmo texto precisa gerar sempre a mesma sequência de caracteres.
Há ainda um aspecto de segurança. O guia da Microsoft lembra que entender os efeitos da normalização é importante ao implementar segurança, já que ataques de spoofing usam caracteres visualmente parecidos de alfabetos diferentes.
O problema da string normalization antes do .NET 10
A normalização Unicode existe no .NET há muito tempo. O problema é que, até o .NET 9, as APIs de normalização funcionavam exclusivamente com o tipo string.
Segundo as novidades das bibliotecas do .NET 10, isso obrigava quem tinha dados em arrays de caracteres ou spans a alocar uma nova string só para chamar essas APIs. Além disso, as APIs que devolvem o texto normalizado sempre alocam uma nova string para representar a saída.
Veja como a string normalization funcionava antes:
using System.Text;
string input = "café"; // Pode estar em diferentes formas Unicode
string normalized = input.Normalize(NormalizationForm.FormC);
Console.WriteLine(normalized);
Nesse código, o método Normalize devolve o resultado como string. Assim, sempre que o texto precisa mudar, a chamada gera uma nova alocação no heap gerenciado.
Agora pense em uma aplicação que recebe grandes volumes de texto, como um processador de entrada de usuários. Se cada requisição normaliza vários campos, essas alocações se acumulam e aumentam a pressão sobre o garbage collector.
O problema se agrava em parsers de texto, no processamento de grandes volumes de dados e em servidores que atendem muitas requisições ao mesmo tempo. Nesses cenários, cada alocação evitada reduz o trabalho do coletor e melhora a previsibilidade da latência.
Se quiser revisar por que cada transformação de texto gera um novo objeto, o artigo sobre strings imutáveis em C# explica o conceito em detalhes.
String normalization baseada em Span no .NET 10
Com o .NET 10, a string normalization passa a aceitar diretamente um ReadOnlySpan<char> como entrada e um Span<char> como destino. Dessa forma, você normaliza dados que vêm de arrays, buffers de rede ou fatias de outras strings sem criar objetos intermediários.
Além disso, o resultado vai para um buffer que você controla. Então, você decide se ele fica na stack, em um array alugado de um pool ou em uma estrutura reaproveitada entre chamadas.
As novas APIs de StringNormalizationExtensions
Os novos métodos de extensão ficam na classe StringNormalizationExtensions, no namespace System:
public static class StringNormalizationExtensions
{
public static int GetNormalizedLength(this ReadOnlySpan<char> source, NormalizationForm normalizationForm = NormalizationForm.FormC);
public static bool IsNormalized(this ReadOnlySpan<char> source, NormalizationForm normalizationForm = NormalizationForm.FormC);
public static bool TryNormalize(this ReadOnlySpan<char> source, Span<char> destination, out int charsWritten, NormalizationForm normalizationForm = NormalizationForm.FormC);
}
Esses métodos oferecem três funcionalidades principais:
GetNormalizedLength: retorna o tamanho estimado da versão normalizada da sequência de caracteres.IsNormalized: verifica se a sequência já está normalizada na forma especificada.TryNormalize: normaliza a sequência sem alocar uma novastring, escrevendo o resultado diretamente em umSpan<char>.
Repare que todos os métodos usam NormalizationForm.FormC como valor padrão. Portanto, se a forma C atende ao seu cenário, você pode omitir o último argumento.
Exemplos práticos de string normalization
Agora vamos ver como a string normalization com spans funciona na prática. Para deixar a diferença visível, os exemplos usam também o texto "café", que representa café com o acento como caractere combinante.
Verificando se um texto já está normalizado
Se o seu código precisa garantir que um texto esteja normalizado antes de usá-lo, comece com IsNormalized:
using System.Text;
ReadOnlySpan<char> composed = "café".AsSpan();
ReadOnlySpan<char> decomposed = "café".AsSpan();
bool composedIsNormalized = composed.IsNormalized(NormalizationForm.FormC);
bool decomposedIsNormalized = decomposed.IsNormalized(NormalizationForm.FormC);
Console.WriteLine($"Composed is normalized? {composedIsNormalized}"); // True
Console.WriteLine($"Decomposed is normalized? {decomposedIsNormalized}"); // False
Esse teste trabalha direto sobre o span, sem exigir uma string intermediária. Por isso, ele funciona bem como caminho rápido da string normalization: se o texto já está na forma desejada, você pula a etapa inteira.
O primeiro resultado considera que o arquivo de código grava o literal "café" na forma composta, o que é comum nos editores atuais. Já o segundo literal usa explicitamente o caractere combinante.
Obtendo o tamanho necessário para a normalização
Antes de aplicar a string normalization, você precisa de um buffer com espaço suficiente. Para saber esse tamanho, use GetNormalizedLength:
using System.Text;
ReadOnlySpan<char> text = "café".AsSpan();
int length = text.GetNormalizedLength(NormalizationForm.FormC);
// O texto original tem 5 caracteres; a forma composta precisa de 4
Console.WriteLine($"Required length for normalization: {length}");
A documentação descreve o retorno como o tamanho estimado da forma normalizada. Assim, trate esse valor como referência para dimensionar o buffer e confira sempre o retorno de TryNormalize.
Vale notar também que a forma normalizada pode ficar maior que a original. Isso acontece, por exemplo, na forma D, que decompõe um caractere composto em dois ou mais code points.
Normalizando um texto sem criar novas strings
Para normalizar sem alocar uma nova string, use TryNormalize com um buffer de destino:
using System.Text;
ReadOnlySpan<char> input = "café".AsSpan();
Span<char> output = stackalloc char[10]; // Aloca na stack para evitar o GC
if (input.TryNormalize(output, out int charsWritten, NormalizationForm.FormC))
{
ReadOnlySpan<char> normalized = output[..charsWritten];
Console.WriteLine($"Normalized length: {normalized.Length}"); // 4
}
else
{
Console.WriteLine("The buffer was not large enough.");
}
Aqui, o Span<char> fica na stack e recebe o resultado, então nenhuma alocação acontece no heap. Em seguida, o parâmetro charsWritten informa quantos caracteres válidos o método escreveu.
Se o buffer for pequeno demais, o método devolve false em vez de lançar exceção. Desse modo, você pode tentar de novo com um buffer maior sem pagar o custo de um try/catch.
Só crie uma string com new string(normalized) quando precisar realmente guardar o resultado. Enquanto o texto circular apenas dentro do método, mantenha tudo em spans.
Escolhendo o buffer: stackalloc ou ArrayPool
O exemplo anterior de string normalization usa stackalloc com tamanho fixo, o que funciona para textos curtos e conhecidos. Contudo, a stack tem espaço limitado, e alocar muito nela com entrada do usuário pode derrubar o processo.
Um padrão comum combina as duas opções. Para textos pequenos, use a stack; para textos maiores, alugue um array do ArrayPool<char>:
using System.Buffers;
using System.Text;
const int StackLimit = 256;
ReadOnlySpan<char> input = "café".AsSpan();
int length = input.GetNormalizedLength(NormalizationForm.FormC);
char[]? rented = null;
Span<char> buffer = length <= StackLimit
? stackalloc char[StackLimit]
: (rented = ArrayPool<char>.Shared.Rent(length));
try
{
if (input.TryNormalize(buffer, out int charsWritten, NormalizationForm.FormC))
{
ReadOnlySpan<char> normalized = buffer[..charsWritten];
Console.WriteLine($"Normalized length: {normalized.Length}");
}
}
finally
{
// Devolve o array ao pool apenas se ele foi alugado
if (rented is not null)
ArrayPool<char>.Shared.Return(rented);
}
Nesse código, o caminho comum da string normalization não aloca nada no heap. Já o caminho raro reaproveita arrays do pool, o que também evita novas alocações na maior parte das chamadas.
Lembre que o ArrayPool pode devolver um array maior que o pedido. Por isso, use sempre charsWritten para delimitar o resultado e nunca o tamanho do array.
Um helper reutilizável de string normalization
Um uso frequente da string normalization é comparar dois textos por equivalência. Com as novas APIs, essa comparação pode acontecer sem criar nenhuma string intermediária:
using System.Buffers;
using System.Text;
public static class NormalizedText
{
public static bool EqualsNormalized(
ReadOnlySpan<char> left,
ReadOnlySpan<char> right,
NormalizationForm form = NormalizationForm.FormC)
{
// Caminho rápido: os dois textos já estão na forma pedida
if (left.IsNormalized(form) && right.IsNormalized(form))
return left.SequenceEqual(right);
char[] leftBuffer = ArrayPool<char>.Shared.Rent(left.GetNormalizedLength(form));
char[] rightBuffer = ArrayPool<char>.Shared.Rent(right.GetNormalizedLength(form));
try
{
if (!left.TryNormalize(leftBuffer, out int leftLength, form) ||
!right.TryNormalize(rightBuffer, out int rightLength, form))
{
throw new InvalidOperationException("The buffer is too small for the normalized text.");
}
return leftBuffer.AsSpan(0, leftLength)
.SequenceEqual(rightBuffer.AsSpan(0, rightLength));
}
finally
{
ArrayPool<char>.Shared.Return(leftBuffer);
ArrayPool<char>.Shared.Return(rightBuffer);
}
}
}
O método testa primeiro se os dois lados já estão normalizados, porque esse caso costuma ser o mais comum. Em seguida, só aluga buffers quando realmente precisa transformar algum texto.
Com isso, uma chamada como NormalizedText.EqualsNormalized("café", "café") retorna true. Por outro lado, um string.Equals ordinal com os mesmos valores retornaria false.
Esse helper serve de ponto de partida. Em produção, meça o ganho com um benchmark antes de substituir chamadas existentes a string.Normalize.
Quando aplicar a string normalization
Normalizar todo texto em todo lugar raramente compensa. Em geral, o melhor momento para aplicar a string normalization é a fronteira da aplicação, logo que o dado chega de uma fonte externa.
Por exemplo, uma API que recebe nomes de usuário pode normalizar o valor antes de validar unicidade e gravar no banco. Assim, todas as comparações posteriores trabalham com uma única representação, e o restante do código não precisa repetir a operação.
Outro cenário comum envolve leitura de arquivos e mensagens em lote. Nesse caso, os dados já chegam em buffers de bytes ou caracteres, e as novas APIs evitam converter cada campo em string só para normalizar.
Por outro lado, texto gerado pela própria aplicação, como mensagens fixas e chaves internas, normalmente já nasce em uma forma conhecida. Então, a string normalization nesses valores costuma ser desperdício de processamento.
Se a regra de negócio exige uma forma específica, documente essa decisão junto ao modelo de dados. Dessa forma, quem consumir os dados sabe que todos os registros seguem a forma C, por exemplo, e não precisa normalizar de novo.
Cuidados ao usar as novas APIs
Os três métodos lançam ArgumentException quando o span contém um code point inválido ou quando a forma de normalização é inválida. Portanto, trate essa exceção ao processar texto vindo de fontes externas.
Outro ponto importante da string normalization é a escolha da forma. Use a forma C para comparar e armazenar texto de forma geral, e reserve as formas K para cenários em que perder formatação é aceitável, como índices de busca.
A normalização também não resolve tudo sozinha. Ela não trata diferenças entre maiúsculas e minúsculas nem remove acentos, então combine com comparações apropriadas quando o cenário pedir.
Por fim, lembre que essas extensões de string normalization chegaram no .NET 10. Se a sua biblioteca também precisa rodar em versões anteriores, mantenha um caminho alternativo com string.Normalize para os outros targets.
Por que isso importa
A introdução dessas APIs traz benefícios concretos para quem trabalha com texto:
- Menos alocações: reduz a necessidade de criar novas strings, evitando uso excessivo de memória.
- Mais desempenho: atende melhor aplicações que processam grandes volumes de texto.
- Melhor gerenciamento de memória: permite manipular
Span<char>eReadOnlySpan<char>sem impactar o garbage collector. - Mais flexibilidade: aceita dados que já estão em arrays, buffers ou fatias de outras strings.
Esse é mais um passo na direção que o .NET segue há várias versões, levando APIs baseadas em spans para áreas que antes dependiam só de string. Se quiser acompanhar outras melhorias de memória dessa versão, veja as mudanças no garbage collector do .NET 10.
Para um panorama mais amplo do tipo string, o guia de manipulação de strings em C# e .NET reúne as principais técnicas em um só lugar.
Conclusão
O novo suporte de string normalization para Span<char> no .NET 10 resolve uma limitação antiga da plataforma. Agora você normaliza texto Unicode sem precisar converter tudo para string e sem gerar uma nova alocação a cada chamada.
Use IsNormalized como caminho rápido, GetNormalizedLength para dimensionar o buffer e TryNormalize para escrever o resultado onde fizer mais sentido. Além disso, combine stackalloc e ArrayPool para manter o caminho comum sem alocações.
Se você trabalha com grandes volumes de dados ou quer otimizar sua aplicação ao máximo, vale testar a nova string normalization e medir o impacto no garbage collector.
FAQ
Em qual versão as APIs de string normalization com Span chegaram?
Elas chegaram no .NET 10, na classe StringNormalizationExtensions. Em versões anteriores, a normalização funciona apenas com o tipo string.
Qual forma de normalização as novas APIs usam por padrão?
Os três métodos usam NormalizationForm.FormC como valor padrão. Você pode informar FormD, FormKC ou FormKD no último parâmetro.
O GetNormalizedLength devolve o tamanho exato?
A documentação descreve o retorno como o tamanho estimado da forma normalizada. Por isso, confira sempre o retorno de TryNormalize e o valor de charsWritten.
O que acontece se o buffer de destino for pequeno?
O TryNormalize devolve false em vez de lançar exceção. Nesse caso, aumente o buffer e chame o método de novo.
Posso usar stackalloc com qualquer tamanho de texto?
Não é recomendado, porque a stack tem espaço limitado. Para textos grandes ou vindos de usuários, use um limite e recorra ao ArrayPool<char> acima dele.
A normalização ignora maiúsculas e acentos?
Não. Ela só unifica representações equivalentes de um mesmo caractere, então combine com comparações apropriadas quando precisar ignorar caixa.