Перейти к содержимому

Коллекции и LINQ

Какую коллекцию выбрать, что происходит внутри List<T> и Dictionary, когда на самом деле выполняется запрос LINQ и чем IQueryable<T> опасен в EF Core. Эти вопросы звучат почти на каждом собеседовании и быстро показывают, понимает ли кандидат цену своего кода.

Перед этой темой: Типы и память, Язык C#.

Junior+

Какие коллекции есть в .NET и как выбрать нужную?

Заголовок раздела «Какие коллекции есть в .NET и как выбрать нужную?»
Ответ

Что это

Коллекция хранит группу элементов. Скорость операций записывают через O: время O(1) не зависит от числа элементов, O(n) растёт пропорционально ему, O(log n) — очень медленно.

Как пользоваться

Задача Коллекция Главная операция
Набор фиксированного размера массив T[] индекс — O(1)
Растущий список List<T> Add и индекс — O(1)
Значение по ключу Dictionary<TKey, TValue> поиск — O(1) в среднем
Уникальные значения HashSet<T> Contains — O(1) в среднем
Очередь: первым пришёл — первым ушёл Queue<T> Enqueue, Dequeue — O(1)
Стек: последним пришёл — первым ушёл Stack<T> Push, Pop — O(1)
Ключи по порядку SortedDictionary<TKey, TValue> поиск — O(log n)

Частая ошибка

Хранить всё в List<T> и вызывать Contains в цикле. Каждая проверка — O(n), а HashSet<T> делает её за O(1).

Как устроено

  • List<T>, Queue<T> и Stack<T> — обёртки над массивом; очередь использует его как кольцевой буфер.
  • Dictionary<TKey, TValue> и HashSet<T> — хеш-таблицы: место элемента вычисляется по хеш-коду ключа.
  • SortedDictionary<TKey, TValue> и SortedSet<T> — красно-чёрное дерево.
  • SortedList<TKey, TValue> — два отсортированных массива: ключей и значений.
  • LinkedList<T> — двусвязный список из отдельных узлов.
  • PriorityQueue<TElement, TPriority> (.NET 6+) — куча на массиве: первым выходит элемент с наименьшим приоритетом.

Когда что выбирать

Пара Первая лучше Вторая лучше
List<T> / LinkedList<T> почти всегда вставка O(1), если узел уже найден
SortedList / SortedDictionary меньше памяти, данные приходят отсортированными частые вставки в случайном порядке
Dictionary / SortedDictionary поиск O(1) нужен обход по порядку ключей

Ловушки

  • List<T>.Insert(0, x) и Remove сдвигают элементы — O(n).
  • Порядок перебора Dictionary и HashSet не гарантирован.
  • В LinkedList<T> нет индекса: найти нужный узел — O(n).

Правило

Начинайте с List<T> или Dictionary. Специализированную коллекцию берите, когда видна операция, которую она ускоряет.

Под капотом

  • Массив и List<T> — непрерывный блок памяти. Перебор идёт по соседним адресам и хорошо попадает в кеш процессора.
  • Узловые коллекции (LinkedList<T>, SortedDictionary, SortedSet) создают объект на элемент: заголовок, ссылки на соседей, разброс по куче.
  • В List<T> со структурами значения лежат прямо в массиве, без boxing.

Цена

  • O(1) у хеш-таблицы — средний случай. Плохой хеш или подобранные коллизии превращают поиск в O(n).
  • На малых n константы важнее асимптотики. Линейный поиск по десятку элементов может обогнать хеширование.
  • Каждый узел — аллокация и работа для GC; на миллионах элементов это видно по паузам сборки.

Компромиссы

Сценарий Выбор
Собрали при старте, дальше только чтение FrozenDictionary, FrozenSet (.NET 8+)
Порядок вставки плюс поиск по ключу OrderedDictionary<TKey, TValue> (.NET 9+)
Задачи с приоритетом PriorityQueue<TElement, TPriority>
Доступ из нескольких потоков System.Collections.Concurrent

Как проверить в проде

Горячие операции с коллекциями видны в профиле CPU (dotnet-trace, PerfView). Аллокации узлов показывает BenchmarkDotNet с [MemoryDiagnoser] на реальных объёмах данных.

Итог: асимптотика задаёт выбор, а локальность памяти и аллокации решают, насколько быстрым он окажется.

Пример
var list = new List<int> { 3, 1, 2 };
var byId = new Dictionary<int, string> { [1] = "Alice", [2] = "Bob" };
var seen = new HashSet<string>();
var queue = new Queue<string>();
var stack = new Stack<string>();
var sorted = new SortedDictionary<string, int>();
Console.WriteLine(list.Contains(2)); // True — линейный поиск, O(n)
if (byId.TryGetValue(2, out var name))
Console.WriteLine(name); // Bob — поиск по ключу, O(1)
Console.WriteLine(seen.Add("x")); // True
Console.WriteLine(seen.Add("x")); // False — дубль не добавлен
queue.Enqueue("a"); queue.Enqueue("b");
stack.Push("a"); stack.Push("b");
Console.WriteLine($"{queue.Dequeue()} {stack.Pop()}"); // a b
sorted["b"] = 2; sorted["a"] = 1;
Console.WriteLine(string.Join(",", sorted.Keys)); // a,b — ключи по порядку
Junior+
Ответ

Что это

  • Count — сколько элементов в списке.
  • Capacity — длина внутреннего массива, всегда не меньше Count.

Зачем

Массив нельзя удлинить. Поэтому List<T> держит запас места и расширяется, когда запас кончился.

Как пользоваться

  1. Пока Count меньше Capacity, Add просто записывает элемент в массив.
  2. Когда массив заполнен, список создаёт новый, примерно вдвое больше, и копирует элементы.
  3. Если размер известен, передайте его в конструктор: new List<Order>(orders.Length).
  4. Clear() обнуляет Count, но оставляет Capacity. Сжать массив можно через TrimExcess().

Частая ошибка

Считать Capacity лимитом размера. Список вырастет сам, а Capacity лишь показывает, сколько места уже выделено.

Как устроено

  1. new List<T>() ссылается на общий пустой массив и ничего не выделяет.
  2. Первый Add создаёт массив на 4 элемента.
  3. Каждое следующее расширение удваивает длину: 8, 16, 32 и так далее.
  4. Если сразу нужно больше (AddRange большой коллекции), берётся требуемый размер.

За n добавлений с удвоением суммарно копируется меньше n элементов. Поэтому Add стоит O(1) в среднем, хотя отдельный вызов может занять O(n).

Когда что выбирать

Ситуация Что делать
Размер известен new List<T>(n) или EnsureCapacity(n) (.NET 6+)
Источник — ICollection<T> new List<T>(source) или AddRange: одно выделение и CopyTo
Список больше не растёт TrimExcess() — сработает, если занято меньше 90 %

Ловушки

  • Insert(0, x) и RemoveAt(0) сдвигают весь массив — O(n). Для очереди есть Queue<T>.
  • Remove и Clear не уменьшают Capacity.
  • Ёмкость из пользовательского ввода ограничивают, иначе по чужому запросу выделится гигантский массив.

Правило

Знаете размер — задайте его сразу. Не знаете — не гадайте и доверьте рост списку.

Под капотом

  • Константа DefaultCapacity = 4 и формула 2 * _items.Length — деталь реализации CoreLib. Опираться на них в коде нельзя.
  • Рост ограничен Array.MaxLength, а новая ёмкость всегда не меньше требуемой.
  • CollectionsMarshal.AsSpan(list) (.NET 5+) даёт Span<T> на внутренний массив без копирования. Пока span используется, размер списка менять нельзя.
  • CollectionsMarshal.SetCount (.NET 8+) задаёт Count напрямую. При увеличении он открывает неинициализированные слоты.

Цена

  • Во время роста живы оба массива. Для List<long> на миллион элементов последний рост выделяет 8 МБ при живых 4 МБ.
  • Массивы от 85 000 байт попадают в LOH. Он не уплотняется по умолчанию, что ведёт к фрагментации и росту рабочего набора.
  • Сразу после роста до половины массива пустует.

Компромиссы

  • Множитель 2 — баланс между числом копирований и перерасходом памяти.
  • Переиспользование списка через Clear() экономит аллокации, но удерживает большой массив.
  • Для временных буферов на горячем пути подходит пул массивов ArrayPool<T>.Shared.

Как проверить в проде

Скорость аллокаций и размер LOH показывает dotnet-counters, источники аллокаций — трасса dotnet-trace. В BenchmarkDotNet цену роста видно в колонке Allocated с [MemoryDiagnoser].

Итог: рост списка — это копирование и мусор, поэтому на горячих путях ёмкость задают заранее.

Пример
var list = new List<int>();
Console.WriteLine(list.Capacity); // 0 — массив ещё не выделен
int last = -1;
for (int i = 0; i < 20; i++)
{
list.Add(i);
if (list.Capacity != last)
{
Console.WriteLine($"Count={list.Count}, Capacity={list.Capacity}");
last = list.Capacity;
}
}
// Count=1, Capacity=4
// Count=5, Capacity=8
// Count=9, Capacity=16
// Count=17, Capacity=32
list.TrimExcess(); // 20 меньше 90 % от 32 — массив сожмётся
Console.WriteLine(list.Capacity); // 20
var presized = new List<int>(1000); // одно выделение вместо девяти массивов
Console.WriteLine(presized.Capacity); // 1000
Junior+

Что такое отложенное выполнение в LINQ и чем опасно повторное перечисление?

Заголовок раздела «Что такое отложенное выполнение в LINQ и чем опасно повторное перечисление?»
Ответ

Что это

Запрос LINQ — рецепт, а не результат. Пока его не перебрали, ни один элемент не прочитан.

Зачем

Запрос можно собрать по частям и не тратить память на промежуточные списки.

Как пользоваться

Вид метода Примеры Когда выполняется
Отложенный Where, Select, OrderBy, Take при переборе результата
Немедленный Count(), First(), Sum(), Any() сразу при вызове
Материализующий ToList(), ToArray(), ToDictionary() сразу; результат сохраняется

Частая ошибка

Вызвать Count(), а потом foreach по тому же запросу. Запрос выполнится дважды. Если результат нужен несколько раз, сохраните его через ToList().

Как устроено

  1. Отложенный оператор возвращает объект, который помнит источник и лямбду.
  2. foreach запрашивает элементы по одному, и каждый оператор цепочки обрабатывает их по очереди.
  3. Потоковые операторы (Where, Select, Take) отдают элемент сразу.
  4. Непотоковые (OrderBy, GroupBy, Reverse) при первом запросе читают весь источник в буфер.

Когда что выбирать

Ситуация Решение
Результат читается один раз оставить ленивым
Нужен несколько раз или нужен снимок ToList() или ToArray()
Данные нужны после закрытия DbContext или файла материализовать до закрытия

Ловушки

  • Замыкание захватывает переменную, а не значение. Изменили переменную до перебора — запрос возьмёт новое значение.
  • Исключение из лямбды вылетает при переборе, а не в строке с Where.
  • Генераторы (Random, DateTime.Now, чтение из сети) при повторном переборе дают другой результат.
  • Изменение List<T> во время его перебора бросает InvalidOperationException.

Правило

Принимаете IEnumerable<T> — перебирайте ровно один раз. Нужно больше — материализуйте в начале метода.

Под капотом

  • Каждый оператор оборачивает источник в объект, который выдаёт элементы по одному через MoveNext и Current.
  • Эти внутренние классы LINQ (в .NET 9 их свели к общему базовому Iterator<TSource>) знают форму друг друга. Where(...).Select(...) склеивается в один объект, а OrderBy(...).First() ищет минимум за O(n) без сортировки.
  • List<T> хранит номер версии. Add или Remove во время перебора меняют его, и следующий MoveNext бросает исключение.

Цена

  • Повторный перебор повторяет всё: делегаты, сортировку, SQL-запрос.
  • Запрос EF Core, возвращённый как IEnumerable<T>, выполнится после Dispose контекста и бросит ObjectDisposedException.
  • Побочные эффекты в лямбдах срабатывают при каждом переборе и только для прочитанных элементов.

Компромиссы

Подход Плюс Минус
Ленивый результат память O(1), ранняя остановка повторная работа, сюрпризы времени жизни
Материализация один проход, снимок данных память под весь результат

Возвращая результат лениво, документируйте это. Нужны Count и несколько проходов — принимайте IReadOnlyCollection<T>.

Как проверить в проде

  • Анализатор CA1851 (с .NET 7, по умолчанию выключен) находит возможное многократное перечисление.
  • Повторные SQL-запросы видны в логах EF Core.
  • В профиле CPU лишние проходы выглядят как неожиданно частые вызовы лямбд.

Итог: ленивость экономит память, но отдаёт время и число выполнений запроса в руки потребителя.

Пример
var numbers = new List<int> { 1, 2, 3 };
var calls = 0;
IEnumerable<int> query = numbers.Where(n =>
{
calls++;
return n > 1;
});
numbers.Add(4); // запрос ещё не выполнялся
Console.WriteLine(query.Count()); // 3 — видит 4, добавленную после объявления
Console.WriteLine(query.Max()); // 4 — второй проход по источнику
Console.WriteLine(calls); // 8 — предикат вызван дважды для каждого элемента
var snapshot = query.ToList(); // материализация: один проход, дальше готовый список
numbers.Add(5);
Console.WriteLine(snapshot.Count); // 3 — снимок не меняется
Middle+

Как устроен Dictionary<TKey, TValue> и зачем согласовывать Equals и GetHashCode?

Заголовок раздела «Как устроен Dictionary<TKey, TValue> и зачем согласовывать Equals и GetHashCode?»
Ответ

Что это

Dictionary<TKey, TValue> хранит пары «ключ — значение» и находит значение по ключу в среднем за O(1). Ключи уникальны и не бывают null.

Как пользоваться

  • dict[key] = value добавляет пару или перезаписывает значение.
  • Add с существующим ключом бросает ArgumentException.
  • Чтение отсутствующего ключа через индексатор бросает KeyNotFoundException, поэтому читают через TryGetValue.

Частая ошибка

Переопределить в классе ключа только Equals. Словарь сначала сравнивает хеш-коды из GetHashCode, и два «равных» объекта с разными хешами не найдут друг друга. Проще всего взять record: он генерирует оба метода сам.

Как устроено

  1. GetHashCode ключа даёт число — хеш-код.
  2. Остаток от деления хеша на размер таблицы выбирает корзину.
  3. Словарь проходит цепочку записей этой корзины: сравнивает сохранённые хеши, при совпадении вызывает Equals.
  4. Сравнивает компаратор IEqualityComparer<TKey>. По умолчанию это EqualityComparer<TKey>.Default, который использует IEquatable<T>, если тип его реализует.

Контракт: если a.Equals(b), то хеши равны. Обратное не требуется: одинаковые хеши у разных ключей — обычные коллизии.

Когда что выбирать

Ключ Как задать равенство
Свой неизменяемый тип record, record struct или IEquatable<T> плюс HashCode.Combine
Тип, который нельзя менять свой IEqualityComparer<TKey> в конструкторе словаря
Строки без учёта регистра StringComparer.OrdinalIgnoreCase

Ловушки

  • Изменяемый ключ: поменяли поле, от которого зависит хеш, — запись осталась в словаре, но недостижима.
  • Equals без GetHashCode даёт лишь предупреждение компилятора CS0659.
  • ContainsKey и потом индексатор — два поиска вместо одного (CA1854). Используйте TryGetValue.
  • Add во время foreach по словарю бросает InvalidOperationException, а Remove с .NET Core 3.0 допустим.

Правило

Ключ — неизменяемый, а Equals и GetHashCode считаются по одним и тем же полям.

Под капотом

  • int[] _buckets хранит индекс первой записи цепочки, Entry[] _entries — записи { hashCode, next, key, value }.
  • Цепочки — индексы внутри одного массива, а не объекты-узлы. Это метод цепочек без аллокации на элемент и с хорошей локальностью.
  • Размер таблицы — простое число. Когда _entries заполнен, создаются массивы не меньше удвоенного размера, и все записи раскладываются заново.
  • В 64-битном процессе деление по модулю заменено умножением (FastMod).
  • Удалённые записи попадают в список свободных слотов и занимаются следующими Add.

Цена

  • Рост — O(n) и всплеск аллокаций, поэтому известный размер задают в конструкторе.
  • Порядок перебора — порядок слотов. Без удалений он совпадает с порядком вставки, после удалений — нет.
  • Плохой хеш складывает ключи в одну цепочку, и поиск становится O(n).

Компромиссы

  • string.GetHashCode рандомизирован в каждом процессе. Это защита от HashDoS — атаки подбором коллизий.
  • Dictionary<string, T> со стандартным или ordinal-компаратором начинает с более быстрого нерандомизированного хеша. Если при вставке цепочка длиннее 100, он переключается на рандомизированный и перехеширует таблицу.
  • Для своих типов ключей такой защиты нет: предсказуемый хеш от пользовательского ввода — уязвимость.

Как проверить в проде

Признак плохого хеша — дорогие TryGetValue и много вызовов Equals в профиле CPU (dotnet-trace, PerfView). Распределение хешей проверяют тестом, скорость — BenchmarkDotNet.

Итог: Dictionary быстр, пока хеш хорошо распределяет ключи, а сами ключи не меняются.

Пример
var key = new MutableKey { Id = 1 };
var dict = new Dictionary<MutableKey, string> { [key] = "first" };
key.Id = 2; // хеш ключа изменился
Console.WriteLine(dict.ContainsKey(key)); // False — запись лежит под старым хешем
Console.WriteLine(dict.ContainsKey(new MutableKey { Id = 1 })); // False — а Equals уже не совпадает
Console.WriteLine(dict.Count); // 1 — запись есть, но недостижима
var prices = new Dictionary<Sku, decimal> { [new Sku(1, "A-100")] = 9.99m };
Console.WriteLine(prices.ContainsKey(new Sku(1, "A-100"))); // True — record struct даёт Equals и GetHashCode
class MutableKey
{
public int Id { get; set; }
public override bool Equals(object? obj) => obj is MutableKey k && k.Id == Id;
public override int GetHashCode() => Id.GetHashCode();
}
readonly record struct Sku(int ShopId, string Code);
Middle+
Ответ

Что это

  • IEnumerable<T> — «умею перебирать элементы»: списки, массивы, результат yield return.
  • IQueryable<T> — «умею собрать запрос к источнику». DbSet<User> в EF Core реализует IQueryable<User>.

Зачем

Чтобы фильтровать данные там, где они лежат. db.Users.Where(u => u.IsActive) станет SQL с WHERE, и из базы придут только активные пользователи.

Как пользоваться

  1. Стройте запрос на IQueryable<T>: Where, OrderBy, Select.
  2. Выполните его в конце: ToListAsync(), FirstOrDefaultAsync().

Частая ошибка

Вызвать ToList() или AsEnumerable() раньше фильтра. Тогда загрузится вся таблица, а Where отработает в памяти.

Как устроено

  • Лямбда компилируется по-разному в зависимости от типа параметра: в Func<T, bool> — в исполняемый код, в Expression<Func<T, bool>> — в дерево объектов, которое можно разобрать.
  • IQueryable<T> наследует IEnumerable<T>. Какой Where вызовется, решает статический тип переменной при компиляции.
  • При переборе провайдер EF Core переводит дерево в SQL. Захваченные переменные становятся параметрами SQL.

Когда что выбирать

Шаг запроса Где выполняется
Where, OrderBy, Select до материализации в базе
Всё после AsEnumerable() или ToList() в памяти
Непереводимый метод в финальном Select в памяти, только эта часть

Ловушки

  • Метод с параметром Func<User, bool> вызовет Enumerable.Where даже на DbSet, и загрузится вся таблица.
  • С EF Core 3.0 непереводимое выражение в Where или OrderBy бросает исключение. Клиентская оценка разрешена только в финальном Select.
  • Сравнение строк и обработку null определяет база, а не C#.

Правило

Фильтры принимайте как Expression<Func<T, bool>>, а в память переходите явно и как можно позже.

Под капотом

  1. Компилятор строит дерево вызовами Expression.Lambda, Expression.Property прямо в месте запроса — это аллокации при каждом выполнении.
  2. Queryable.Where добавляет узел вызова метода и вызывает Provider.CreateQuery.
  3. При переборе EF Core ищет запрос в кеше по форме дерева. При промахе транслирует его в SQL и компилирует код материализации.

Цена

  • Константы, вшитые в дерево при динамическом построении через Expression.Constant, дают новую форму на каждое значение. Это перекомпиляция в EF и засорение кеша планов в базе.
  • AsQueryable() над коллекцией в памяти даёт EnumerableQuery<T>: дерево компилируется в делегат при выполнении, и это медленнее обычного LINQ to Objects.

Компромиссы

  • EF.CompileQuery и EF.CompileAsyncQuery убирают построение и сравнение дерева. Ограничения: одна модель и простые скалярные параметры.
  • IQueryable<T> из репозитория гибок, но наружу протекают детали хранилища и время жизни DbContext. Частый компромисс — материализованные DTO или спецификации на Expression<Func<T, bool>>.

Как проверить в проде

  • Сгенерированный SQL показывают ToQueryString() и логирование через LogTo.
  • Метрика EF Core Query Cache Hit Rate стабильно ниже 100 % — признак, что что-то ломает кеш запросов.

Итог: IQueryable<T> переносит работу в базу, но требует следить за формой дерева и границей между сервером и клиентом.

В памяти
using System.Linq.Expressions;
IQueryable<int> source = new[] { 1, 2, 3, 4 }.AsQueryable();
Expression<Func<int, bool>> expr = n => n > 2; // дерево выражений: код как данные
Func<int, bool> func = n => n > 2; // обычный делегат
IQueryable<int> q1 = source.Where(expr); // Queryable.Where: фильтр стал частью запроса
IEnumerable<int> q2 = source.Where(func); // Enumerable.Where: фильтр выполнится в памяти
Console.WriteLine(q1.Expression); // System.Int32[].Where(n => (n > 2))
Console.WriteLine(q2 is IQueryable<int>); // False — провайдер о фильтре не знает
EF Core
using System.Linq.Expressions;
// db — DbContext с DbSet<User> Users
// 1. Фильтр в SQL: SELECT ... WHERE IsActive
var active = await db.Users.Where(u => u.IsActive).ToListAsync();
// 2. Фильтр в памяти: из базы читается вся таблица
var slow = db.Users.AsEnumerable().Where(u => u.IsActive).ToList();
// 3. Та же ловушка, но незаметная: Func выбирает Enumerable.Where
List<User> Find(Func<User, bool> filter) => db.Users.Where(filter).ToList();
// 4. Правильно: Expression оставляет запрос IQueryable
List<User> FindQ(Expression<Func<User, bool>> filter) => db.Users.Where(filter).ToList();
Console.WriteLine(db.Users.Where(u => u.IsActive).ToQueryString()); // текст SQL
Middle+

Как работает yield return и что генерирует компилятор?

Заголовок раздела «Как работает yield return и что генерирует компилятор?»
Ответ

Что это

Итератор — метод, который возвращает IEnumerable<T> и выдаёт элементы через yield return. yield break досрочно завершает последовательность.

Зачем

  • Не нужен промежуточный список: элементы создаются по запросу.
  • Можно описать бесконечную последовательность и взять только начало через Take.

Как пользоваться

  1. Вызов метода лишь создаёт объект-последовательность.
  2. foreach запускает тело метода до первого yield return и получает элемент.
  3. Следующий шаг цикла продолжает тело с места остановки.
  4. Новый foreach начинает всё с начала.

Частая ошибка

Ждать, что проверка аргументов в начале итератора сработает при вызове. Исключение вылетит только при первом переборе.

Как устроено

  • Компилятор создаёт вложенный класс, который реализует IEnumerable<T>, IEnumerator<T> и IDisposable.
  • Параметры и локальные переменные, которые живут между yield, становятся его полями.
  • Перечислитель — объект с MoveNext() и Current. Здесь MoveNext() — switch по номеру состояния: прыжок в точку после последнего yield return.
  • Dispose() выполняет блоки finally и using, если перебор прервали. foreach вызывает его всегда, даже при break.
  • Reset() сгенерированного перечислителя бросает NotSupportedException.

Когда что выбирать

Задача Инструмент
Ленивая последовательность в памяти IEnumerable<T> с yield return
Элементы приходят асинхронно (сеть, база) async IAsyncEnumerable<T> и await foreach (C# 8+)
Результат нужен целиком и несколько раз метод, возвращающий List<T>

Асинхронный вариант называют асинхронным потоком; отмену передают через WithCancellation.

Ловушки

  • yield нельзя использовать в методах с параметрами ref, out, in, в лямбдах, в блоках catch и finally и в try с catch. В try только с finally — можно.
  • Ручной GetEnumerator() без using пропускает Dispose, и finally не выполнится.

Правило

Делите итератор на два метода: публичный сразу проверяет аргументы и возвращает результат приватного итератора, например локальной функции.

Под капотом

  • Поля получают непроизносимые имена: <>1__state (состояние), <>2__current (текущий элемент), <>l__initialThreadId (поток-создатель).
  • Исходные значения параметров хранятся отдельно, например <>3__max, чтобы каждый новый перечислитель стартовал с них.
  • Первый GetEnumerator в потоке-создателе возвращает сам объект. Повторный вызов или вызов из другого потока создаёт копию, поэтому параллельные foreach по одному результату не мешают друг другу.

Цена

  • Каждый вызов итератора — аллокация объекта.
  • Каждый элемент — два интерфейсных вызова: MoveNext и Current.
  • Ресурс в using внутри итератора живёт, пока потребитель не дочитает до конца или не вызовет Dispose.

Компромиссы

  • Коллекции BCL пишут перечислители вручную как структуры: foreach по List<T> не аллоцирует, если тип известен статически.
  • В .NET 9 перечислитель групп GroupBy и ToLookup переписали вручную: сгенерированный компилятором код на этом горячем пути оказался медленнее.
  • С C# 13 в итераторах можно объявлять ref-локальные переменные и использовать unsafe-блоки, если они не пересекают yield.

Как проверить в проде

Аллокации итераторов видны в BenchmarkDotNet с [MemoryDiagnoser] и в трассе dotnet-trace. Сгенерированный код смотрят декомпилятором: ILSpy или SharpLab.

Итог: yield return — удобный генератор кода, а за удобство платят аллокацией и интерфейсными вызовами.

Пример
var seq = Evens(-1); // исключения нет: тело ещё не запускалось
foreach (var n in Evens(10))
{
Console.WriteLine(n);
if (n == 2) break; // foreach вызовет Dispose, и выполнится finally
}
// start
// 0
// 2
// finally
static IEnumerable<int> Evens(int max)
{
if (max < 0) throw new ArgumentOutOfRangeException(nameof(max)); // сработает при первом MoveNext
Console.WriteLine("start");
try
{
for (int i = 0; i <= max; i += 2)
yield return i;
}
finally
{
Console.WriteLine("finally");
}
}
Что примерно генерирует компилятор · Senior
using System.Collections;
// Упрощённый аналог итератора: for (i = 0; i <= max; i += 2) yield return i;
sealed class EvensIterator(int max) : IEnumerable<int>, IEnumerator<int>
{
private int _state; // 0 — не начат, 1 — после yield, -1 — завершён
private int _i; // локальная переменная стала полем
public int Current { get; private set; }
object IEnumerator.Current => Current;
public bool MoveNext()
{
switch (_state)
{
case 0: _i = 0; break; // первый вызов: начало тела
case 1: _i += 2; break; // возврат в точку после yield return
default: return false;
}
if (_i <= max) { Current = _i; _state = 1; return true; }
_state = -1;
return false;
}
public IEnumerator<int> GetEnumerator() => this; // настоящий код создаёт копию, если this уже занят
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public void Dispose() => _state = -1;
public void Reset() => throw new NotSupportedException();
}
Senior+

Чем отличаются ReadOnly-, Immutable- и Frozen-коллекции?

Заголовок раздела «Чем отличаются ReadOnly-, Immutable- и Frozen-коллекции?»
Ответ

Что это

Вид Примеры Что гарантирует
Read-only IReadOnlyList<T>, ReadOnlyCollection<T> через неё нельзя изменить
Immutable ImmutableArray<T>, ImmutableList<T> не меняется никогда
Frozen (.NET 8+) FrozenDictionary<TKey, TValue>, FrozenSet<T> не меняется и быстро ищет

Зачем

Чтобы отдать данные наружу и быть уверенным, что их не испортят.

Как пользоваться

  • list.AsReadOnly() — обёртка для публичного свойства.
  • ToImmutableArray(), ToImmutableList() — неизменяемая копия.
  • ToFrozenDictionary(), ToFrozenSet() — справочник, который строят один раз.

Частая ошибка

Вызвать Add у immutable-коллекции и проигнорировать результат. Исходная коллекция не изменится: Add вернул новую.

Как устроено

  • IReadOnlyList<T> — только интерфейс. List<T> его реализует, и приведение (List<T>)view снимает защиту.
  • ReadOnlyCollection<T> — обёртка над IList<T>. От приведения она защищает, но изменения исходного списка видны через неё.
  • ImmutableArray<T> — структура над массивом: индекс O(1), любое изменение копирует весь массив.
  • ImmutableList<T> — AVL-дерево: индекс, Add, Remove — O(log n).
  • Frozen-коллекции при создании анализируют ключи и выбирают специализированную реализацию поиска.

Когда что выбирать

Сценарий Выбор
Свойство, которое нельзя менять снаружи ReadOnlyCollection<T>
Снимок, изменения редки ImmutableArray<T>
Частые изменения с сохранением версий ImmutableList<T>, ImmutableDictionary
Справочник на всё время жизни FrozenDictionary, FrozenSet

Ловушки

  • default(ImmutableArray<T>) не инициализирован: IsDefault вернёт true, а перебор бросит NullReferenceException.
  • Цикл for по ImmutableList<T> стоит O(n log n). Перебирайте через foreach.
  • Серия изменений immutable-коллекции создаёт много промежуточных копий. Для неё есть ToBuilder() и ToImmutable().

Правило

Документация советует ImmutableArray<T>, когда изменения редки или элементов мало, и ImmutableList<T>, когда изменения частые.

Под капотом

  • ImmutableList<T> хранит AVL-дерево. «Изменение» создаёт O(log n) новых узлов на пути от корня, остальные узлы общие со старой версией.
  • FrozenDictionary выбирает реализацию по данным. Для строк — группы по длине или хеш по уникальной подстроке, для плотных целых ключей и enum в .NET 10 — обычный массив.
  • В .NET 9 у Frozen-коллекций появился alternate lookup: поиск по ReadOnlySpan<char> без создания строки.

Цена

  • Immutable-деревья — объект на элемент, указатели вместо массива, O(log n) на чтение и заметно больше памяти, чем у List<T>.
  • Frozen-коллекция дорого строится. Документация предупреждает: только для доверенных ключей, от них зависит время построения.

Компромиссы

  • Снимок конфигурации, который подменяют целиком, — ImmutableArray<T> или Frozen*: читатели работают без блокировок.
  • История состояний (undo, версии) — ImmutableList<T> или ImmutableDictionary.
  • Публичный API — IReadOnlyList<T> в сигнатуре. Если вызывающему коду нельзя доверять — ReadOnlyCollection<T> или копия.

Как проверить в проде

Выигрыш Frozen зависит от ключей, поэтому решение подтверждают BenchmarkDotNet: TryGetValue против Dictionary и стоимость построения, умноженная на число пересозданий. Память узлов immutable-деревьев покажет снимок кучи dotnet-gcdump.

Итог: выбирайте по нужной гарантии и профилю нагрузки: много чтений — массив или frozen, много изменений — дерево.

Пример
using System.Collections.Frozen;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
var source = new List<int> { 1, 2, 3 };
IReadOnlyList<int> view = source; // только интерфейс
ReadOnlyCollection<int> wrapper = source.AsReadOnly(); // обёртка без методов изменения
ImmutableArray<int> snapshot = source.ToImmutableArray(); // копия, не меняется никогда
FrozenSet<int> frozen = source.ToFrozenSet(); // копия, оптимизированная под поиск
source.Add(4);
Console.WriteLine($"{view.Count} {wrapper.Count} {snapshot.Length} {frozen.Count}"); // 4 4 3 3
((List<int>)view).Add(5); // интерфейс не защищает от приведения
ImmutableArray<int> next = snapshot.Add(10); // новый экземпляр; snapshot не изменился
Console.WriteLine($"{snapshot.Length} {next.Length}"); // 3 4
Console.WriteLine(frozen.Contains(2)); // True
Senior+
Ответ

Что это

LINQ — удобная надстройка над циклами. За удобство платят служебными объектами и вызовом лямбды для каждого элемента.

Зачем

В обычном коде разница незаметна. В цикле на миллионы итераций она складывается в миллисекунды и мегабайты мусора.

Как пользоваться

  • Any() вместо Count() > 0: Any() останавливается на первом элементе.
  • Свойство Count или Length вместо метода Count() у списков и массивов.
  • ToList(), если запрос нужен несколько раз.
  • HashSet<T> вместо List<T>, если внутри Where вызывается Contains.

Частая ошибка

Мерить скорость через Stopwatch в Debug-сборке. Результат будет неверным; правильный инструмент — BenchmarkDotNet в Release.

Как устроено

  • Каждый оператор — объект-итератор.
  • Лямбда — делегат. Если она захватывает переменную, создаётся ещё и объект замыкания.
  • На каждый элемент — вызовы MoveNext и Current через интерфейс плюс вызов делегата.
  • Count() проверяет, реализует ли источник ICollection<T>, и тогда берёт Count без перебора. Ленивый запрос он перебирает целиком.

Когда что выбирать

Вместо Лучше
Count() > 0 Any() или Count > 0 у коллекции
GroupBy(x => x.Key).Select(g => (g.Key, g.Count())) CountBy(x => x.Key) (.NET 9+)
Where(x => list.Contains(x.Id)) HashSet<T> для Contains
LINQ в цикле на миллионы итераций for или foreach, если бенчмарк подтвердил

Ловушки

Анализаторы ловят типовые ошибки: CA1829 (свойство Length или Count вместо Count()), CA1827 (Any() вместо Count()), CA1860 (свойство вместо Any() у коллекций), CA1851 (многократное перечисление).

Правило

Переписывайте только измеренное:

  1. Вынесите код в класс с методами [Benchmark] и атрибутом [MemoryDiagnoser].
  2. Запустите в Release без отладчика.
  3. Сравните LINQ и цикл на реалистичных размерах данных через [Params].

Под капотом

  • Внутренние итераторы LINQ передают друг другу информацию: Where(...).Select(...) — один объект, Skip(...).Take(...) над List<T> работает по индексам.
  • В .NET 9 Any, All, Count, First и Single с предикатом получили быстрый путь через ReadOnlySpan<T> для массивов и List<T>.
  • В .NET 10 Contains получил специализации для цепочек операторов.
  • Там же JIT девиртуализирует интерфейсные методы массивов, а escape-анализ во многих случаях размещает на стеке делегаты и перечислители массивов и List<T>, которые не покидают метод.

Цена

  • Объект замыкания JIT в .NET 10 пока не убирает.
  • Лямбду без захвата компилятор кеширует в статическом поле. Лямбда, захватывающая this или локальную переменную, создаёт делегат при каждом вызове.
  • Статическая лямбда (static x => ..., C# 9) гарантирует отсутствие захвата.

Компромиссы

  • Для Native AOT LINQ по умолчанию собирается в варианте «под размер» без части оптимизаций. В .NET 10 это переключается свойством UseSizeOptimizedLinq.
  • Цикл выигрывает в скорости, но проигрывает в читаемости; переписывают только подтверждённо горячий код.

Как проверить в проде

  • dotnet-counters: скорость аллокаций и частота сборок поколения 0.
  • dotnet-trace или PerfView: горячие стеки и типы аллокаций.
  • BenchmarkDotNet с несколькими рантаймами (--runtimes net8.0 net10.0): результаты на .NET 8 и .NET 10 могут заметно различаться.

Итог: «LINQ медленный» — гипотеза, которую проверяют профилем и бенчмарком на целевой версии .NET.

Пример
// NuGet: BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<LinqVsLoop>();
[MemoryDiagnoser] // добавит колонку Allocated
public class LinqVsLoop
{
private readonly int[] _data = Enumerable.Range(0, 10_000).ToArray();
private readonly int _threshold = 5_000;
[Benchmark(Baseline = true)]
public int Loop()
{
int sum = 0;
foreach (var x in _data)
if (x > _threshold) sum += x;
return sum; // 0 байт аллокаций
}
[Benchmark]
public int Linq() => SumAbove(_data, _threshold);
private static int SumAbove(int[] data, int threshold) =>
data.Where(x => x > threshold).Sum(); // замыкание, делегат и итератор Where
}