Evitando alocações com StringBuilder.MoveChunks no .NET 11
O StringBuilder.MoveChunks é um novo método estático do .NET 11 que transfere todo o conteúdo de um StringBuilder para uma nova instância sem copiar nenhum caractere. Ele chegou no .NET 11 Preview 5 e passou quase despercebido entre as novidades voltadas à performance.
À primeira vista, a API parece simples demais para merecer atenção. No entanto, a motivação por trás dela revela muito sobre os desafios de performance em bibliotecas de infraestrutura do ecossistema .NET, sobretudo no compilador Roslyn.
Neste artigo, você vai entender o que o método faz, como ele funciona internamente, qual a diferença para o Clear() e em quais cenários ele realmente faz sentido.
O problema com strings e alocações
No .NET, o tipo string é imutável. Por isso, cada operação que aparentemente modifica uma string cria, na prática, um novo objeto na memória.
Esse comportamento é previsível e correto para a maioria dos casos. Contudo, em cenários de manipulação intensiva de texto, ele gera uma pressão constante sobre o Garbage Collector (GC).
Se você quer revisar esse conceito com calma, o artigo sobre strings imutáveis em C# explica por que a plataforma adotou esse modelo. Além disso, o texto sobre como o Garbage Collector funciona mostra o custo real de tantas alocações curtas.
Para contornar esse problema, a plataforma oferece o StringBuilder. Ele é uma classe mutável do namespace System.Text, projetada exatamente para construir strings de forma eficiente.
Como o StringBuilder funciona internamente
Internamente, o StringBuilder não mantém uma única string contínua. Em vez disso, ele usa vários buffers do tipo char[], chamados de chunks (fragmentos), organizados como uma lista encadeada.
Segundo a documentação oficial do StringBuilder, a capacidade padrão de uma instância criada sem parâmetros é de 16 caracteres. Quando o texto ultrapassa essa capacidade, a classe aloca um novo chunk e o liga aos anteriores.
Quando você chama Append(), os dados vão direto para esses buffers, sem criar objetos intermediários. Somente na chamada a ToString() a classe consolida todos os chunks em um único objeto string.
Esse modelo é extremamente eficiente para construir texto. Porém, ele tem um custo final inevitável: a chamada a ToString() sempre aloca uma nova string na heap.
Em textos pequenos, esse custo quase não aparece. Já em arquivos grandes, como os que um source generator produz, essa última cópia pode ser a maior alocação de todo o processo.
Existe ainda o método GetChunks(), disponível há algumas versões, que permite percorrer os chunks como ReadOnlyMemory<char> sem gerar uma string. Contudo, a documentação alerta que ele só é seguro enquanto ninguém modifica o StringBuilder, então ele não resolve o problema de posse do conteúdo.
O que é o StringBuilder.MoveChunks
O .NET 11 Preview 5 introduziu o seguinte método estático na classe StringBuilder:
public static StringBuilder MoveChunks(StringBuilder source);
Conceitualmente, o StringBuilder.MoveChunks realiza a mesma operação que o código abaixo. A diferença é que ele faz isso de forma muito mais eficiente:
// Cria um novo StringBuilder com o conteúdo do original
var snapshot = new StringBuilder(source.ToString());
// O original é "zerado"
source.Clear();
Em vez de copiar os dados via ToString(), o método transfere a estrutura interna de chunks da origem para uma nova instância. Ou seja, nenhum caractere é duplicado na memória durante a operação.
A página de novidades das bibliotecas no .NET 11 resume o comportamento assim: o método move todo o conteúdo para um novo StringBuilder, e a origem fica com comprimento zero.
Exemplo de uso do StringBuilder.MoveChunks
O comportamento do método fica claro no exemplo a seguir:
using System.Text;
var source = new StringBuilder();
source.AppendLine("Adding ");
source.AppendLine("some text");
// Transfere os chunks de source para uma nova instância
StringBuilder snapshot = StringBuilder.MoveChunks(source);
Console.Write(snapshot.ToString()); // "Adding \nsome text\n"
Console.WriteLine(source.ToString()); // ""
Console.WriteLine(source.Length); // 0
// source continua válido e pode ser reutilizado
source.Append("ready to be reused");
Após a chamada, snapshot contém todo o conteúdo que estava em source. Enquanto isso, source fica completamente vazio e pronto para ser reutilizado.
Repare que source não se torna inválido nem lança exceção depois da transferência. Assim, qualquer código que ainda o utiliza continua funcionando, apenas sem o conteúdo antigo.
Como o StringBuilder.MoveChunks funciona por dentro
A implementação do StringBuilder.MoveChunks é surpreendentemente simples. Em vez de copiar dados, ela apenas transfere as referências internas, como mostra esta versão resumida do código do runtime:
namespace System.Text;
public sealed partial class StringBuilder
{
public static StringBuilder MoveChunks(StringBuilder source)
{
ArgumentNullException.ThrowIfNull(source);
// Cria nova instância copiando o estado interno do source
StringBuilder destination = new StringBuilder(source);
// Reseta os campos internos do source para um estado "limpo"
source.m_ChunkChars = [];
source.m_ChunkPrevious = null;
source.m_ChunkLength = 0;
source.m_ChunkOffset = 0;
return destination;
}
// Construtor privado de "movimentação"
private StringBuilder(StringBuilder from)
{
m_ChunkLength = from.m_ChunkLength;
m_ChunkOffset = from.m_ChunkOffset;
m_ChunkChars = from.m_ChunkChars;
m_ChunkPrevious = from.m_ChunkPrevious;
m_MaxCapacity = from.m_MaxCapacity;
AssertInvariants(); // Apenas em modo Debug
}
}
Primeiro, o construtor privado copia para a nova instância as referências do chunk atual, do chunk anterior e dos contadores. Em seguida, o método reseta os campos da origem, que passa a apontar para um array vazio.
Por isso, a operação é O(1). Não importa quantos dados o StringBuilder contenha, a transferência sempre custa o mesmo tempo, porque o runtime reaproveita as referências e não copia nenhum byte.
Vale notar também que o m_MaxCapacity acompanha a nova instância. Dessa forma, o destino respeita o mesmo limite máximo de capacidade que a origem tinha.
Repare também que o array vazio atribuído à origem não ocupa espaço útil. Assim, a instância de origem só volta a consumir memória quando alguém chamar Append() de novo.
Por fim, a chamada a ArgumentNullException.ThrowIfNull garante que o método falhe cedo quando recebe null. Isso segue o padrão de validação que as APIs modernas do .NET adotam.
Diferença entre StringBuilder.MoveChunks e Clear
Uma distinção importante separa o StringBuilder.MoveChunks do método Clear(). Embora os dois deixem a instância vazia, eles tratam a memória de formas opostas.
Quando você chama Clear(), os arrays char[] internos continuam na instância. O método zera apenas o comprimento e os metadados, então os buffers ficam disponíveis para as próximas chamadas a Append().
Com o MoveChunks(), por outro lado, a origem perde todas as referências aos chunks. Eles agora pertencem ao novo StringBuilder retornado, e a origem fica verdadeiramente vazia, sem nenhum buffer pré-alocado.
Essa diferença tem implicações práticas que dependem do padrão de uso. Em um laço que reutiliza o mesmo StringBuilder, o Clear() pode ser mais eficiente, porque evita novas alocações de buffers.
Já o MoveChunks() é a escolha ideal quando o conteúdo precisa ir para outro contexto com garantia de que ninguém mais vai alterá-lo.
Por que reatribuir variáveis não é suficiente
Ao analisar o método, surge uma pergunta legítima. Por que não fazer simplesmente isto?
StringBuilder source = new();
// Aponta a nova variável para o source existente
StringBuilder snapshot = source;
// Cria uma nova instância e reatribui source
source = new();
Em muitos casos, essa abordagem seria funcionalmente equivalente. No entanto, existe um cenário em que ela falha: quando outros componentes do código guardam uma referência à instância original.
Nesse caso, qualquer código que ainda segura a referência anterior pode continuar modificando o objeto. Com isso, a integridade dos dados em snapshot fica comprometida.
Em outras palavras, reatribuir a variável muda apenas para onde o seu código aponta, e não para onde o código dos outros aponta. É exatamente para resolver esse problema que o StringBuilder.MoveChunks existe.
A motivação real: source generators e Roslyn
O principal caso de uso que motivou a criação dessa API está nos source generators. Eles são componentes que produzem código C# em tempo de compilação.
Na prática, é muito comum montar o código gerado com um StringBuilder. Depois de montar todo o conteúdo, o desenvolvedor precisa criar uma instância de SourceText para entregá-la ao compilador Roslyn.
O problema é que as APIs públicas atuais do SourceText aceitam apenas string, Stream, TextReader ou byte[]:
namespace Microsoft.CodeAnalysis.Text;
public abstract class SourceText
{
public static SourceText From(string text, Encoding? encoding = null, ...);
public static SourceText From(TextReader reader, int length, ...);
public static SourceText From(Stream stream, ...);
public static SourceText From(byte[] buffer, int length, ...);
}
Assim, o desenvolvedor precisa chamar ToString() no StringBuilder. Essa chamada cria uma alocação extra que pode ser bastante grande em geradores que produzem arquivos extensos.
Além disso, source generators rodam dentro da IDE a cada alteração relevante no código. Portanto, uma alocação grande repetida muitas vezes afeta diretamente a experiência de quem está editando o projeto.
A proposta de integração com o Roslyn
A proposta em discussão na issue 61326 do Roslyn é adicionar uma nova sobrecarga ao SourceText:
public abstract class SourceText
{
public static SourceText From(
StringBuilder stringBuilder,
Encoding? encoding = null,
SourceHashAlgorithm checksumAlgorithm = SourceHashAlgorithm.Sha1);
}
O desafio técnico está em uma garantia. O Roslyn precisa ter certeza de que ninguém vai modificar o StringBuilder depois da criação do SourceText, pois, do contrário, o texto-fonte ficaria inconsistente.
Se o Roslyn recebesse apenas a referência ao objeto, qualquer código com acesso à instância original poderia alterar o conteúdo. Para evitar isso sem uma cópia completa dos dados, a solução passa pelo StringBuilder.MoveChunks.
Como o StringBuilder.MoveChunks garante a imutabilidade
Ao chamar StringBuilder.MoveChunks(source), o Roslyn receberia uma nova instância com todos os chunks originais. Enquanto isso, a origem ficaria vazia.
Todo código que ainda segura a referência ao source continuaria com uma instância válida, porém sem conteúdo. Dessa forma, ninguém mais conseguiria modificar os dados que agora estão sob responsabilidade do compilador.
Essa garantia é exatamente o que permite ao Roslyn usar o StringBuilder como backing store do SourceText. Com isso, o compilador evita clonar os dados e elimina a alocação gerada pelo ToString().
Em resumo, o método transforma uma referência compartilhada em uma posse exclusiva. É o mesmo princípio de transferência de propriedade que outras linguagens tratam no nível do compilador.
Estado atual e limitações
A API StringBuilder.MoveChunks está disponível desde o .NET 11 Preview 5, e o anúncio da versão aparece no artigo sobre o lançamento do .NET 11 Preview 5. Por outro lado, a sobrecarga SourceText.From(StringBuilder) continua apenas como proposta e ainda não foi implementada.
Existe também um obstáculo relevante. Atualmente, source generators precisam ter como target o netstandard2.0.
Isso significa que, mesmo que o Roslyn implemente a sobrecarga internamente, os desenvolvedores não poderiam usá-la diretamente nos seus geradores. Afinal, o netstandard2.0 não enxerga APIs que só existem no .NET 11.
Uma alternativa possível seria fazer vendor do tipo interno StringBuilderText, que já existe como implementação privada no Roslyn. Essa abordagem não dependeria do .NET 11 e poderia trazer ganhos parecidos para geradores que constroem muitas strings.
Ainda assim, copiar código interno de outro projeto exige cuidado com licença e manutenção. Por isso, essa saída precisa de uma avaliação antes de qualquer adoção.
StringBuilder.MoveChunks em bibliotecas
Mesmo fora do contexto do Roslyn, o StringBuilder.MoveChunks abre um padrão interessante. Ele ajuda autores de bibliotecas que precisam aceitar texto construído por chamadores externos.
O padrão seria este:
using System.Text;
// O chamador monta o texto
var builder = new StringBuilder();
builder.Append("...");
// A biblioteca recebe a posse do conteúdo sem cópia
StringBuilder transferred = StringBuilder.MoveChunks(builder);
library.ProcessText(transferred);
// builder ainda é válido, mas está vazio
builder.Append("new content");
Dessa forma, a biblioteca ganha controle exclusivo sobre os dados. Além disso, ela elimina a necessidade de uma alocação intermediária via ToString().
Na prática, essa assinatura deixa explícito no código quem é o dono do texto em cada momento. Isso facilita a leitura e reduz o risco de alterações acidentais em dados que outro componente já está processando.
Considerações de performance
O uso do StringBuilder.MoveChunks tem um trade-off importante em cenários de reutilização de instâncias em cache.
Quando você mantém um StringBuilder em pool e o limpa com Clear(), os buffers internos continuam lá. Portanto, na próxima chamada a Append(), não há necessidade de alocar novos arrays char[].
Com o MoveChunks(), a origem perde seus buffers. Assim, a próxima chamada a Append() vai alocar arrays novos.
Em cenários de alta frequência com objetos em pool, isso pode significar mais alocações de char[] do que a abordagem com Clear(). Isso acontece mesmo que o número de alocações de string diminua.
Por isso, a escolha depende do padrão de uso. Se o conteúdo precisa ir para outro componente com garantia de não mutação, o MoveChunks() é a ferramenta certa.
Para uma simples limpeza e reutilização no mesmo contexto, o Clear() continua sendo preferível. Além disso, vale medir com um benchmark antes de trocar uma abordagem pela outra, porque o resultado varia conforme o tamanho e a frequência do texto.
Se você está começando a estudar essa classe, o artigo sobre string, System.String e StringBuilder em C# ajuda a consolidar a base antes de partir para otimizações desse nível.
Conclusão
O StringBuilder.MoveChunks é uma API pequena com uma motivação sofisticada. Ela não resolve um problema que os desenvolvedores encontram todos os dias.
Contudo, ela habilita uma classe importante de otimizações em bibliotecas de infraestrutura, especialmente em source generators e no compilador Roslyn.
Ao transferir os chunks internos sem nenhuma cópia, o método elimina a alocação de string mais cara do pipeline de geração de código. Ao mesmo tempo, ele preserva as garantias de imutabilidade de que o Roslyn precisa.
Para o dia a dia, a regra é simples: use Clear() para reutilizar e MoveChunks() para transferir a posse do conteúdo.
FAQ
O StringBuilder.MoveChunks já está disponível para uso em produção?
O método chegou no .NET 11 Preview 5 e fica disponível para produção com o lançamento oficial do .NET 11. Ele não existe em versões anteriores nem no netstandard2.0.
Qual a diferença entre chamar MoveChunks e simplesmente reatribuir a variável?
Reatribuir a variável funciona quando não há outras referências à instância original. O MoveChunks() é necessário quando terceiros ainda guardam essa referência e você precisa impedir que eles alterem o conteúdo transferido.
Posso usar MoveChunks nos meus source generators hoje?
Não diretamente, porque source generators precisam ter como target o netstandard2.0. Uma alternativa é copiar o tipo interno StringBuilderText do Roslyn, o que exige avaliação cuidadosa de licença e manutenção.
Quando devo preferir Clear a MoveChunks?
Use Clear() quando quiser reutilizar o mesmo StringBuilder no mesmo contexto. Use MoveChunks() quando precisar transferir o conteúdo para outro componente com garantia de que ninguém vai modificá-lo.
O MoveChunks é thread-safe?
Não, porque o próprio StringBuilder não é thread-safe. O MoveChunks() não adiciona nenhuma garantia de concorrência.
A sobrecarga SourceText.From(StringBuilder) já existe no Roslyn?
Ainda não, ela continua como uma proposta aberta. A API MoveChunks() no runtime é o primeiro passo para viabilizá-la tecnicamente.