O .NET 8 introduziu FrozenDictionary<TKey, TValue> e FrozenSet<T> no namespace System.Collections.Frozen. Eles resolvem um problema real: quando você tem dados que são construídos uma vez (na inicialização da aplicação) e depois apenas lidos repetidamente em caminhos de alta frequência, o Dictionary padrão desperdiça capacidade de sincronização e hashing que nunca será usada. As Frozen Collections trocam flexibilidade de escrita por velocidade máxima de leitura.
O problema que elas resolvem
Considere cenários comuns em aplicações .NET de produção:
- Tabelas de configuração carregadas na inicialização (países, estados, códigos de erro, permissões)
- Lookup tables de domínio (tipos de produto, categorias, mapeamentos de status)
- Dicionários de localização (strings de UI por idioma)
- Caches de metadados via reflection (como mapeamento de propriedades)
Nesses casos, você quer apenas TryGetValue e Contains — nunca Add ou Remove. O Dictionary<TKey, TValue> funciona, mas carrega overhead de uma estrutura mutável: buckets com espaço para crescimento, versioning interno para detecção de modificações concorrentes, e hashing genérico.
O ImmutableDictionary resolve a imutabilidade, mas seu custo de lookup é maior que o Dictionary comum — ele usa uma árvore AVL internamente, o que penaliza leitura para garantir imutabilidade estrutural com custo O(log n).
As Frozen Collections ficam no meio-termo ideal: construção lenta (O(n) com otimização do perfect hashing), leitura ultra-rápida.
API básica
using System.Collections.Frozen;
// Construção — feita uma vez
var paises = new Dictionary<string, string>
{
["BR"] = "Brasil",
["US"] = "Estados Unidos",
["PT"] = "Portugal",
// ... outros países
}.ToFrozenDictionary();
// Leitura — feita milhões de vezes
if (paises.TryGetValue("BR", out var nome))
Console.WriteLine(nome); // Brasil
// FrozenSet
var codigosPermitidos = new HashSet<string> { "ADM", "GER", "SUP" }
.ToFrozenSet();
bool isPermitido = codigosPermitidos.Contains("ADM"); // true
A API imita exatamente IDictionary<K,V> e ISet<T>. A transição de código existente é praticamente zero.
Como funciona internamente
Durante o ToFrozenDictionary(), o runtime analisa as chaves e escolhe a estratégia de hashing mais rápida para aquele conjunto específico:
- SmallValueTypeComparableFrozenDictionary — para dicionários pequenos (< ~10 itens) com value types comparáveis
- StringComparerFrozenDictionary — otimizado para chaves
string, usa perfect hashing baseado em substrings das chaves - Int32FrozenDictionary — para chaves
int, usa acesso direto por índice quando possível - DefaultFrozenDictionary — caso geral com hashing otimizado após análise do conjunto
A ideia central do perfect hashing para strings: o runtime inspeciona as chaves e identifica uma combinação de posição + comprimento que diferencia todas elas univocamente. Por exemplo, para as chaves {"BR", "US", "PT"}, basta comparar o primeiro caractere — não precisa fazer hash da string inteira. Esse atalho pode tornar o lookup 2–4× mais rápido que o hash genérico.
Benchmark real
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80)]
public class FrozenBenchmark
{
private Dictionary<string, int> _dict = null!;
private ImmutableDictionary<string, int> _immutable = null!;
private FrozenDictionary<string, int> _frozen = null!;
private string[] _keys = null!;
[Params(10, 100, 1000)]
public int Size;
[GlobalSetup]
public void Setup()
{
var source = Enumerable.Range(0, Size)
.ToDictionary(i => $"chave_{i:D4}", i => i);
_dict = new Dictionary<string, int>(source);
_immutable = source.ToImmutableDictionary();
_frozen = source.ToFrozenDictionary();
_keys = source.Keys.ToArray();
}
[Benchmark(Baseline = true)]
public int Dictionary_TryGetValue()
{
var sum = 0;
foreach (var k in _keys)
if (_dict.TryGetValue(k, out var v)) sum += v;
return sum;
}
[Benchmark]
public int ImmutableDictionary_TryGetValue()
{
var sum = 0;
foreach (var k in _keys)
if (_immutable.TryGetValue(k, out var v)) sum += v;
return sum;
}
[Benchmark]
public int FrozenDictionary_TryGetValue()
{
var sum = 0;
foreach (var k in _keys)
if (_frozen.TryGetValue(k, out var v)) sum += v;
return sum;
}
}
Resultados típicos (máquina x64, .NET 8 Release):
| Método | Size | Média | Ratio | Alocações |
|---|---|---|---|---|
| Dictionary | 100 | 1.82 µs | 1.00× | 0 B |
| ImmutableDictionary | 100 | 6.54 µs | 3.59× | 0 B |
| FrozenDictionary | 100 | 1.09 µs | 0.60× | 0 B |
| Dictionary | 1000 | 19.1 µs | 1.00× | 0 B |
| ImmutableDictionary | 1000 | 77.3 µs | 4.05× | 0 B |
| FrozenDictionary | 1000 | 11.2 µs | 0.59× | 0 B |
O FrozenDictionary é consistentemente ~40% mais rápido que Dictionary e ~4× mais rápido que ImmutableDictionary para leituras em conjuntos de tamanho médio.
Casos de uso práticos
1. Lookup table de domínio registrada como Singleton
// Registro na DI
builder.Services.AddSingleton(sp =>
{
var db = sp.GetRequiredService<AppDbContext>();
// Carregado uma vez no startup, nunca mais modificado
return db.Categorias
.AsNoTracking()
.ToList()
.ToFrozenDictionary(c => c.Codigo, c => c.Descricao);
});
// Uso no handler (injeção de FrozenDictionary direto)
public class ProdutoHandler(FrozenDictionary<string, string> categorias)
{
public string ResolverCategoria(string codigo)
=> categorias.TryGetValue(codigo, out var desc) ? desc : "Desconhecida";
}
2. Mapeamento de permissões por role
public static class PermissaoConfig
{
public static readonly FrozenDictionary<string, FrozenSet<string>> PorRole =
new Dictionary<string, string[]>
{
["admin"] = ["criar", "editar", "deletar", "visualizar"],
["gerente"] = ["criar", "editar", "visualizar"],
["operador"] = ["visualizar"],
}
.ToFrozenDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ToFrozenSet(StringComparer.OrdinalIgnoreCase)
);
public static bool Pode(string role, string acao)
=> PorRole.TryGetValue(role, out var permissoes) && permissoes.Contains(acao);
}
// No middleware de autorização — chamado em cada request
if (!PermissaoConfig.Pode(user.Role, "criar"))
return Results.Forbid();
3. Tabela de lookup de códigos HTTP custom
public static class ErrorCatalog
{
private static readonly FrozenDictionary<int, ErrorInfo> _catalog =
LoadFromJson("errors.json").ToFrozenDictionary(e => e.Code);
public static ErrorInfo? Get(int code)
=> _catalog.TryGetValue(code, out var info) ? info : null;
}
Quando NÃO usar
As Frozen Collections têm trade-offs claros:
- Construção lenta:
ToFrozenDictionary()é O(n) com constante maior que oDictionary. Não use em hot paths de construção. - Sem updates: são completamente imutáveis. Se precisar atualizar durante a vida da aplicação, use
ConcurrentDictionaryou o padrão de snapshot comInterlocked.Exchange. - Conjuntos muito pequenos (< 4 itens): o overhead de construção não compensa. Um simples array com busca linear é mais rápido.
- Conjuntos muito grandes (> 100k itens) com chaves altamente aleatórias: o ganho de perfect hashing diminui e o custo de construção aumenta. Meça antes de adotar.
Compatibilidade com interfaces existentes
FrozenDictionary<K,V> implementa IReadOnlyDictionary<K,V> e IReadOnlyCollection<KeyValuePair<K,V>>, mas não IDictionary<K,V>. Se seu código usa IDictionary diretamente, você precisará ajustar as interfaces. A refatoração é mínima na maioria dos casos.
// Funciona
IReadOnlyDictionary<string, int> config = dados.ToFrozenDictionary();
// Não compila — FrozenDictionary não é IDictionary
IDictionary<string, int> config = dados.ToFrozenDictionary(); // erro
FrozenSet para verificações de existência
FrozenSet<T> segue a mesma lógica: construção lenta, Contains ultrarrápido. Útil para listas de IDs permitidos, palavras reservadas, extensões de arquivo válidas:
public static class ExtensoesBloqueadas
{
private static readonly FrozenSet<string> _set =
new[] { ".exe", ".bat", ".ps1", ".sh", ".cmd", ".vbs" }
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public static bool EstaBloqueada(string extensao)
=> _set.Contains(extensao);
}
// No upload handler — chamado em cada arquivo enviado
if (ExtensoesBloqueadas.EstaBloqueada(Path.GetExtension(arquivo.FileName)))
return Results.BadRequest("Extensão não permitida");
Conclusão
FrozenDictionary e FrozenSet são a escolha certa sempre que você tem dados que são carregados uma vez e lidos muito frequentemente. O ganho de 40% em lookups sobre o Dictionary — sem nenhuma alocação extra — pode fazer diferença real em APIs de alta frequência onde essas estruturas estão no caminho crítico de cada request.
O padrão de adoção é simples: construa seus dados normalmente com Dictionary/HashSet, chame .ToFrozenDictionary() / .ToFrozenSet() no final da inicialização, e registre como Singleton na DI. O restante do código não muda.
Precisa resolver isso na prática?
Performance em APIs de alta frequência muitas vezes é questão de escolher a estrutura de dados certa no momento certo. Se você tem gargalos de performance e quer um diagnóstico técnico, podemos ajudar.
Falar com um especialista →