C# - Cinco recursos pouco usados
   Aposto que você não está usando estas cinco APIs do .NET e pelo menos uma delas pode mudar completamente a forma como você aborda um problema de desempenho. (Traduzido e formatado com ajuda da IA e revisado por Macoratti)

Existem recursos na plataforma .NET que a maioria dos desenvolvedores usa sem nunca pesquisar a fundo. Dictionary<TKey, TValue>, string, List<T>, Span<T>, todos tipos familiares, todos perfeitamente capazes de levar uma aplicação muito longe.

 

Então você começa a trabalhar em códigos onde a mesma operação acontece milhões de vezes, e de repente os detalhes importam. É aqui que algumas dos recursos menos discutidos da plataforma .NET se tornam interessantes.

A plataforma contém APIs especificamente projetadas para esses cenários, mas são fáceis de ignorar porque não são necessárias para a maioria dos códigos de aplicação.

Você pode construir softwares perfeitamente funcionais sem nunca tocar em FrozenDictionary, SearchValues<T>, String.Create(), ReadOnlyMemory<T> ou CollectionsMarshal.AsSpan(). Mas uma vez que você entende para que foram projetadas, elas se tornam ferramentas úteis para ter à disposição quando o problema certo aparecer.

Vamos examinar estes recursos.

1. Use FrozenDictionary para Dados de Consulta Somente Leitura

Um Dictionary<TKey, TValue> normal é projetado para ser mutável.

Você adiciona entradas, remove entradas, atualiza valores e faz consultas. Isso é exatamente o que você quer quando a coleção muda ao longo do seu ciclo de vida.

Mas o que acontece quando ela não muda ?

Imagine uma aplicação que constrói uma tabela de consulta durante a inicialização e depois lê a partir dela pelo resto de seu tempo de vida.

Exemplo:
 
var codigosStatus = new Dictionary<int, string>
{
    [200] = "OK",
    [201] = "Criado",
    [400] = "Requisição Inválida",
    [401] = "Não Autorizado",
    [404] = "Não Encontrado",
    [500] = "Erro Interno do Servidor"
};
Não há nada de errado nisso.

Mas se o dicionário se torna efetivamente imutável após a construção, você ainda está usando uma estrutura de dados projetada em torno da possibilidade de mutação.

FrozenDictionary<TKey, TValue>
existe especificamente para o cenário oposto.

Exemplo:
 
var codigosStatus = new Dictionary<int, string>
{
    [200] = "OK",
    [201] = "Criado",
    [400] = "Requisição Inválida",
    [401] = "Não Autorizado",
    [404] = "Não Encontrado",
    [500] = "Erro Interno do Servidor"
}.ToFrozenDictionary();
A parte importante é entender o compromisso.

Criar uma coleção congelada não é gratuito. O tempo de execução pode gastar trabalho adicional analisando e construindo a representação otimizada. Isso é intencional. O FrozenDictionary é projetado com a premissa de que você pagará esse custo uma vez e depois realizará muitas consultas posteriormente.

Isso o torna particularmente interessante para coisas como tabelas de protocolos, configuração estática, mapas de palavras-chave, mapeamentos de tipos MIME, metadados de roteamento e outros dados de consulta inicializados uma vez e consultados repetidamente.

Ele também te dá uma garantia semântica útil. Uma vez criada, a coleção não pode ser modificada.
Isso é diferente de simplesmente decidir não mutar um dicionário normal.

Se você tem um dicionário que muda ao longo do ciclo de vida da aplicação, o FrozenDictionary não é o substituto certo. O custo de reconstruir repetidamente uma coleção congelada anularia o seu propósito.

A ideia interessante aqui não é "o FrozenDictionary é mais rápido que o Dictionary."  É que o tempo de execução pode fazer compromissos diferentes quando sabe que uma coleção nunca mudará.

E esse é um padrão que vale a pena reconhecer muito além desta única API.

2. Use SearchValues<T> para Buscas Repetidas

Pesquisar através de texto é enganosamente caro. Uma chamada simples como:
 
if (texto.Contains(':'))
{
    // ...
}

é trivial. Mas analisadores frequentemente precisam procurar repetidamente por um entre vários caracteres.  Talvez você esteja procurando por separadores:  , ; | :

Uma implementação ingênua pode chamar IndexOf() repetidamente para cada caractere.
 
int virgula = texto.IndexOf(',');
int pontoVirgula = texto.IndexOf(';');
int pipe = texto.IndexOf('|');
int doisPontos = texto.IndexOf(':');

Isso significa pesquisar repetidamente a mesma entrada por valores diferentes.

O SearchValues<T> é projetado para a situação em que você está pesquisando repetidamente por um valor a partir de um conjunto conhecido.

 
private static readonly SearchValues<char> Separadores =
    SearchValues.Create(",;|:");

int indice = texto.AsSpan().IndexOfAny(Separadores);

A parte interessante é que o conjunto de valores que você está procurando pode ser preparado uma vez e reutilizado.Isso é particularmente útil em analisadores, tokenizadores, processamento de protocolos e outros códigos onde o mesmo conjunto de delimitadores é pesquisado repetidamente.

É também um bom exemplo de por que a otimização orientada a benchmark importa. Se você está pesquisando uma string de 20 caracteres uma vez, criar um SearchValues<char> reutilizável não vai de repente transformar sua aplicação.

Se você está processando gigabytes de texto e pesquisando repetidamente pelo mesmo punhado de delimitadores, a economia muda consideravelmente. A API existe porque o tempo de execução pode otimizar a busca quando sabe o conjunto de valores com antecedência.

Essa é a parte que vale a pena lembrar. Não busque por SearchValues<T> porque parece mais sofisticado do que IndexOfAny(). Busque por ele quando a busca em si for repetida o suficiente para que preparar os valores de busca tenha a chance de se pagar.

3. Use String.Create() Quando Construir Strings em Caminhos Críticos (Hot Paths)

A concatenação de strings melhorou drasticamente ao longo dos anos.

O compilador e o tempo de execução são muito melhores em otimizar padrões comuns de construção de strings do que costumavam ser, então você não deve assumir automaticamente que cada operador + cria uma pilha de strings temporárias.

Ainda existem situações em que você está construindo strings repetidamente e sabe exatamente quão grande o resultado final precisa ser. É aí que o String.Create() se torna interessante.

Suponha que você esteja formatando um identificador:
 
string id = $"USER-{userId:D8}";

Para o código de aplicação normal, eu provavelmente deixaria isso em paz.

É legível, óbvio e provavelmente rápido o suficiente. Mas imagine que você está gerando milhões de identificadores de formato fixo em um caminho crítico. Você pode querer um controle mais rígido sobre a alocação final da string.
 
string id = string.Create(
    13,
    userId,
    static (span, value) =>
    {
        "USER-".AsSpan().CopyTo(span);
        value.TryFormat(span[5..], out _, "D8");
    });

String.Create() aloca a string final uma vez e te dá um span gravável sobre o seu armazenamento de caracteres para que você possa populá-lo diretamente.

Não há strings intermediárias para as peças sendo montadas. Esta é uma ferramenta especializada, e é exatamente assim que eu a trataria.

O código é consideravelmente mais complicado do que a interpolação. Se você está gerando uma string durante uma requisição, a complexidade provavelmente não é justificada.

Se você está dentro de um formatador de alta vazão onde alocações aparecem em um profiler, no entanto, controlar a alocação final da string pode valer a pena.

A lição mais ampla é mais útil do que memorizar String.Create().

Quando você vê a construção de string em um caminho crítico, não a reescreva imediatamente porque você assume que a interpolação é lenta. Meça primeiro. O .NET moderno já otimizou muitos casos comuns.

String.Create() é algo que você busca quando estabeleceu que a construção de strings e suas alocações são realmente relevantes para a carga de trabalho.

4. Compreenda ReadOnlyMemory<T> Quando os Dados Devem Sobreviver a um Span<T>

Span<T> é um dos tipos mais úteis introduzidos no .NET moderno, mas ele vem com uma limitação fundamental.

Um span é projetado para representar uma visão sobre a memória sem assumir a propriedade dessa memória. Ele pode apontar para arrays, memória de pilha, memória não gerenciada e outras fontes, mas seu tempo de vida é deliberadamente limitado.

É por isso que você não pode simplesmente armazenar um Span<T> em um campo de classe normal.
 
public class BufferHolder
{
    private Span<byte> _buffer;
}

Isso não compila.

Span<T>
é um ref struct, o que significa que o compilador impõe regras estritas sobre onde ele pode viver e quanto tempo pode sobreviver.

É aí que o ReadOnlyMemory<T> se torna útil.
 
public sealed class Message
{
    private readonly ReadOnlyMemory<byte> _data;

    public Message(ReadOnlyMemory<byte> data)
    {
        _data = data;
    }

    public ReadOnlySpan<byte> Data => _data.Span;
}

Agora a memória pode ser armazenada como parte de um objeto normal e convertida em um span quando o processamento síncrono precisar.

Essa distinção é importante.

Pense em Span<T> como uma visão temporária e de alto desempenho sobre a memória.

Pense em Memory<T> ou ReadOnlyMemory<T> como uma representação armazenável dessa memória.
Isso se torna especialmente útil quando operações assíncronas estão envolvidas. Um Span<T> não pode cruzar com segurança um limite de await, enquanto Memory<T> é especificamente projetado para cenários onde a memória precisa permanecer acessível após o frame de pilha atual ter desaparecido.

Ainda há uma questão de propriedade, no entanto.

ReadOnlyMemory<T> não torna magicamente os dados subjacentes imutáveis. Ele apenas previne mutação através daquela visão específica.

Se o array subjacente mudar em outro lugar, os dados representados pela memória podem mudar também.

É por isso que entender a propriedade e o tempo de vida do buffer subjacente é tão importante quanto entender o tipo que você está usando.

O modelo mental útil é simples:

Use Span<T> quando precisar de uma visão de curta duração e alto desempenho. Use ReadOnlyMemory<T> quando essa visão precisar ser armazenada ou sobreviver além do escopo síncrono atual.

Essa distinção vai te poupar muita frustração assim que você começar a construir APIs em torno de pipelines, buffers e processamento assíncrono.

5. Use CollectionsMarshal.AsSpan() Quando um List<T> Precisa de Processamento Baseado em Span

List<T> é uma das coleções mais comumente usadas no .NET. Ela também é suportada internamente por um array.

Às vezes você tem um List<T> mas precisa passar seu conteúdo para uma API que trabalha com Span<T>.
A abordagem óbvia é copiar os elementos para outro array.Isso funciona, mas derrota um dos principais benefícios de usar um span em primeiro lugar.

CollectionsMarshal.AsSpan() te dá acesso direto ao armazenamento subjacente da lista.
 
var values = new List<int>
{
    10, 20, 30, 40, 50
};
Span<int> span = CollectionsMarshal.AsSpan(values);
for (int i = 0; i < span.Length; i++)
{
    span[i] *= 2;
}

Nenhum novo array é criado.

O span se refere diretamente ao armazenamento interno da lista, de modo que modificações feitas através do span são refletidas na lista.

Isso é poderoso, mas há um porém.

O span não é um snapshot independente da lista.

Se a lista mudar de uma forma que cause a substituição do seu buffer interno, o span que você já obteve não pode mais ser confiável com segurança.

Isso significa que você deve manter o tempo de vida do span curto e evitar modificar a estrutura da lista enquanto estiver usando o span.

Há outro detalhe importante: CollectionsMarshal.AsSpan() não é um convite para tratar List<T> como um contêiner de span de propósito geral.

Se todo o seu algoritmo opera naturalmente em memória contígua, usar um array ou outra representação amigável a span desde o início pode ser mais limpo.

CollectionsMarshal.AsSpan() é mais útil quando você já tem um List<T> e precisa fazer a ponte eficiente dele para APIs baseadas em span sem pagar por uma cópia.

Esse é um problema muito específico, mas quando você o encontra, esta API pode remover uma alocação e cópia que de outra forma seriam desnecessárias.

Conclusão

A coisa interessante sobre estas APIs não é que elas sejam alternativas universalmente mais rápidas aos tipos e padrões que já usamos.

Elas não são. A diferença é que o .NET moderno te dá ferramentas mais especializadas quando a carga de trabalho exige.

A verdadeira habilidade é reconhecer a forma do problema primeiro, depois escolher a abstração que combina com ele. Uma vez que você começa a olhar para o .NET dessa forma, a BCL se torna muito mais interessante.

Há otimizações escondidas à vista de todos, mas elas são mais úteis quando você entende as restrições em torno das quais foram projetadas.

E estamos conversados..

"E andai em amor, como também Cristo nos amou, e se entregou a si mesmo por nós, em oferta e sacrifício a Deus, em cheiro suave."
Efésios 5:2

Referências:


José Carlos Macoratti