Привет, Хабр!

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

Между строк здесь три правила:

  1. Frozen строим только с корректным comparer под ваш домен. Для протокольных ключей почти всегда Ordinal/OrdinalIgnoreCase.

  2. Не рассчитываем, что ToFrozenDictionary автоматически подхватит comparer исходника. Подаем явным аргументом.

  3. Закладываемся на 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-м курсам в месяц по цене одного. Подробнее

Комментарии (1)


  1. iamkisly
    01.10.2025 10:24

    Я просто оставлю это тут

    https://habr.com/ru/articles/837926/