Привет, Хабр!
В проде полно таблиц и маппингов, которые создаются один раз и потом живут годами на чистом чтении. Раньше выбирали между ReadOnlyDictionary и Immutable*. Первый не ускоряет доступ и просто прикрывает исходную коллекцию, второй дает чистые апдейты, но платит временем построения и lookup. В.NET 8 появился третий путь для такого профиля: System.Collections.Frozen.
Задача у Frozen простая и приземленная. Заплатить за построение структуры один раз на старте, а дальше получать быстрый TryGetValue/Contains и предсказуемое перечисление без блокировок. Контейнер неизменяемый, потокобезопасен для чтения и специально заточен под lookup. Стоимость сборки выше обычной, это ожидаемо, поэтому применять его есть смысл там, где чтений на порядки больше, чем конструирований.
С.NET 9 стало еще удобнее: появился alternate lookup. Теперь словарь со строковыми ключами может принимать ReadOnlySpan<char> прямо на lookup, без лишних аллокаций. Это хорошо заходит в веб‑пути, парсеры заголовков и любые сценарии, где строка у вас уже как span.
Рассмотрим тему Frozen‑коллекций подробнее.
Базовая точка опоры: API и ожидания
FrozenDictionary и FrozenSet лежат в пространстве имен System.Collections.Frozen. Они неизменяемые и оптимизированы под быстрый lookup/перечисление. Важные моменты по dictonary из доков:
Высокая стоимость построения, быстрые операции чтения. Подходит для сценария «создали раз на старте и используем весь жизненный цикл».
Только доверенные ключи при инициализации.
Есть методы CopyTo(Span<>) и GetValueRefOrNullRef, а также механизм AlternateLookup для альтернативного типа ключа.
Есть явные перегрузки ToFrozenDictionary/ToFrozenSet, плюс селекторы ключа/значения.
При дубликатах ключей побеждает последний элемент входной последовательности. Это намеренно и отличается от ToDictionary, которое кидает исключение. (
С FrozenSet история аналогичная: неизменяемый набор, быстрый Contains и перечисление, есть AlternateLookup.
Теперь практически.
using System.Collections.Frozen;
var raw = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["content-type"] = "Content-Type",
["accept-encoding"] = "Accept-Encoding",
["x-request-id"] = "X-Request-Id",
// дубликат для демонстрации last-wins
["x-request-id"] = "X-Request-ID"
};
// Важно: компаратор передаем явно.
// Это убережет от чувствительности к регистру и сюрпризов между версиями.
var headers = raw.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
// Рабочее использование
if (headers.TryGetValue("ACCEPT-ENCODING", out var canonical))
{
// canonical == "Accept-Encoding"
}
Между строк здесь три правила:
Frozen строим только с корректным comparer под ваш домен. Для протокольных ключей почти всегда Ordinal/OrdinalIgnoreCase.
Не рассчитываем, что ToFrozenDictionary автоматически подхватит comparer исходника. Подаем явным аргументом.
Закладываемся на last‑wins при дубликатах.
Дальше разберем, как выжать максимум из API.
Alternate lookup в .NET 9: lookup по ReadOnlySpan без аллокаций
В.NET 9 в коллекции приехал механизм AlternateLookup. Идея простая: коллекция хранит ключи типа T, но вы можете искать по альтернативному типу TAlternate. Для строк это применимо, когда на руках ReadOnlySpan из парсера и делать ToString не хочется. FrozenDictionary/FrozenSet отдают AlternateLookup через методы GetAlternateLookup/TryGetAlternateLookup.
EqualityComparer.Default этого не гарантирует, так что нужный comparer надо задать явно при сборке.
using System;
using System.Collections.Frozen;
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["content-length"] = 1,
["connection"] = 2
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
// Берем альтернативный lookup для ReadOnlySpan<char>
var alt = map.GetAlternateLookup<ReadOnlySpan<char>>();
// Где-то в парсере HTTP у нас span без аллокаций
ReadOnlySpan<char> key = "CONTENT-LENGTH".AsSpan();
if (alt.TryGetValue(key, out var id))
{
// получили id без ToString и аллокаций
}
AlternateLookup есть не только у Frozen, но и у Dictionary/HashSet/ConcurrentDictionary в.NET 9. Это общий механизм.
Теперь представим middleware, который нормализует заголовки и фильтрует hop‑by‑hop.
using System.Collections.Frozen;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
var builder = WebApplication.CreateBuilder(args);
var normalized = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["x-request-id"] = "X-Request-Id",
["traceparent"] = "Traceparent",
["x-correlation"] = "X-Correlation"
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
var hopByHop = new[]
{
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
// Альтернативные lookup для спанов
var nAlt = normalized.GetAlternateLookup<ReadOnlySpan<char>>();
var hAlt = hopByHop.GetAlternateLookup<ReadOnlySpan<char>>();
var app = builder.Build();
app.Use(async (ctx, next) =>
{
var rewritten = new HeaderDictionary();
foreach (var kv in ctx.Request.Headers)
{
var nameSpan = kv.Key.AsSpan();
if (hAlt.Contains(nameSpan))
continue;
var canon = nAlt.TryGetValue(nameSpan, out var v) ? v : kv.Key;
rewritten[canon] = kv.Value;
}
ctx.Request.Headers.Clear();
foreach (var kv in rewritten)
ctx.Request.Headers[kv.Key] = kv.Value;
await next();
});
app.MapGet("/", () => Results.Ok("ok"));
await app.RunAsync();
Здесь все тяжелое произошло на старте. На горячем пути нет аллокаций на строки, сравнение идет через span, что поддерживается в.NET 9 на уровне StringComparer.*.
Сделаем паузу и пройдемся по некоторым проблемам
Компараторы, кейс и регрессии
Для строк почти всегда берите Ordinal или OrdinalIgnoreCase. Это максимально предсказуемо и быстро. Был ряд багов в ранних сборках.NET 8 в связке с регистронезависимой логикой и оптимизациями выбора внутренней реализации для строк. Треки в dotnet/runtime это фиксируют. Вывод простой: держите тесты на кейс‑инсенситив и используйте явный comparer при сборке.
Отдельный момент: AlternateLookup по span для строк работает тогда, когда коллекция построена с корректным StringComparer, который поддерживает IAlternateEqualityComparer. Для EqualityComparer.Default это не гарантируется, и именно для этого есть отдельная задача на реализацию. Простой рецепт: всегда подавайте StringComparer.Ordinal/OrdinalIgnoreCase.
Что внутри FrozenDictionary и почему lookup быстрый
FrozenDictionary это абстракция с несколькими специализированными реализациями, которые выбираются фабрикой на этапе построения на основе набора ключей и компаратора. В исходниках есть DefaultFrozenDictionary, специализированные ветки для строковых ключей, а также вспомогательная структура FrozenHashTable. Именно анализ ключей на этапе построения позволяет уложить данные плотно и упростить путь поиска.
Важно понимать следствие: благодаря иммутабельности можно позволить себе более агрессивные стратегии укладки, чем в обычных изменяемых коллекциях, и не тратить ресурсы на поддержку мутаций. А значит, в среднем lookup быстрее, но платим заранее временем построения.
Взаимодействие с JSON: что сериализуется, а что нет
Сериализатор System.Text.Json умеет писать коллекции, если они перечисляемы, поэтому FrozenDictionary можно сериализовать как объект ключ‑значение. Но десериализация обратно в FrozenDictionary из коробки невозможна, так как тип абстрактный и строится через фабрику. Значит, нужен конвертер, который читает во временный Dictionary и затем фризит. Для Newtonsoft.Json поддержки Frozen до сих пор нет.
Пример конвертера для System.Text.Json:
using System;
using System.Collections.Frozen;
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed class FrozenDictionaryJsonConverter<TValue>
: JsonConverter<FrozenDictionary<string, TValue>>
{
public override FrozenDictionary<string, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var tmp = JsonSerializer.Deserialize<Dictionary<string, TValue>>(ref reader, options);
if (tmp is null) return null;
return tmp.ToFrozenDictionary(StringComparer.Ordinal);
}
public override void Write(Utf8JsonWriter writer, FrozenDictionary<string, TValue> value, JsonSerializerOptions options)
{
// Пишем как обычный объект
writer.WriteStartObject();
foreach (var kv in value)
{
writer.WritePropertyName(kv.Key);
JsonSerializer.Serialize(writer, kv.Value, options);
}
writer.WriteEndObject();
}
}
Добавляем его один раз:
var opts = new JsonSerializerOptions
{
WriteIndented = true
};
opts.Converters.Add(new FrozenDictionaryJsonConverter<int>());
// JsonSerializer.Serialize/Deserialize теперь знают про FrozenDictionary<string,int>
Для Newtonsoft.Json понадобится аналогичный JsonConverter.
Ошибки проектирования, которых легко избежать
Частые апдейты набора. Если таблица меняется часто, пересборка съест профит. Либо остаемся на Dictionary/ConcurrentDictionary, либо собираем Frozen на смену снапшота и делаем атомарный свитч, как выше.
Слишком маленький набор. На крошечных наборах выигрыш может не проявиться, Frozen внутри может выбрать стратегию линейного поиска для нескольких элементов, это нормально. Решение: не оптимизировать преждевременно.
Неверный comparer. Для строк Ordinal/OrdinalIgnoreCase. Особенно важно, если планируете AlternateLookup по span.
Надежда на порядок. Не опирайтесь на порядок перечисления как на контракт. Используйте четкую семантику словаря/набора.
Инициализация невалидированными ключами. Документация предупреждает: ключи влияют на стоимость построения. Не кормим построитель мусором из внешнего мира, валидируем заранее.
Еще пару примеров кода
Перебор без foreach за счет коллекций Keys/Values:
var keys = headers.Keys; // это коллекция ключей
var values = headers.Values;
int count = headers.Count;
using var e = headers.GetEnumerator();
while (e.MoveNext())
{
var (k, v) = (e.Current.Key, e.Current.Value);
// ...
}
Копирование в заранее выделенный буфер:
Span<KeyValuePair<string,string>> buf = stackalloc KeyValuePair<string,string>[128];
if (headers.Count <= buf.Length)
{
headers.CopyTo(buf); // метод есть в API
// далее работаем с buf без дополнительных аллокаций
}
AlternateLookup и парсинг CSV‑ключей без ToString:
var alt = headers.GetAlternateLookup<ReadOnlySpan<char>>();
ReadOnlySpan<char> csv = "content-type,accept-encoding,foo";
int start = 0;
while (true)
{
int idx = csv[start..].IndexOf(',');
var token = (idx < 0) ? csv[start..].Trim() : csv.AsSpan(start, idx).Trim();
if (token.Length > 0 && alt.TryGetValue(token, out var canon))
{
// распознали каноническое имя
}
if (idx < 0) break;
start += idx + 1;
}
Что удобно: меняем реализацию коллекции, а интерфейс кода почти не трогаем.
Набор часто нужен для быстрых Contains. FrozenSet идеально подходит для whitelist/blacklist, токенов, допустимых значений фич‑флагов. Те же правила: явный comparer, AlternateLookup для span в.NET 9.
using System.Collections.Frozen;
var blocked = new[]
{
"DROP", "ALTER", "TRUNCATE", "ATTACH", "DETACH"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
// fast-path валидации
bool bad = blocked.Contains("drop");
И для набора в.NET 9 тоже есть AlternateLookup, чтобы проверять ReadOnlySpan без аллокаций.
Вывод
Frozen‑коллекции решают конкретную задачу. Если у вас есть длинноживущие таблицы для быстрого чтения, FrozenDictionary и FrozenSet будут правильным выбором. В.NET 9 AlternateLookup снимает лишние аллокации при парсинге строк, что хорошо чувствуется в веб‑пути. Критично задать правильный comparer при сборке, принять семантику last‑wins на дубликатах и валидировать ключи до заморозки. Взамен вы получаете быстрый и простой в сопровождении код без блокировок на чтении и с предсказуемым поведением под нагрузкой.
Если вас заинтересовали возможности FrozenDictionary и FrozenSet в C#, а также оптимизация доступа к данным через AlternateLookup в.NET 9, стоит обратить внимание на системное изучение языка и его современных инструментов. Понимание этих деталей позволяет писать эффективный и предсказуемый код при работе с большими неизменяемыми структурами данных.
Специализация «C# Developer» поможет изучить язык с нуля: от базовых конструкций до продвинутых тем — работа с коллекциями, LINQ, асинхронностью и многопоточностью.
Рост в IT быстрее с Подпиской — дает доступ к 3-м курсам в месяц по цене одного. Подробнее
iamkisly
Я просто оставлю это тут
https://habr.com/ru/articles/837926/