Привет, Хабр!
В проде полно таблиц и маппингов, которые создаются один раз и потом живут годами на чистом чтении. Раньше выбирали между 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-м курсам в месяц по цене одного. Подробнее
