Коллекции и LINQ
Какую коллекцию выбрать, что происходит внутри List<T> и Dictionary, когда на самом деле выполняется запрос LINQ и чем IQueryable<T> опасен в EF Core. Эти вопросы звучат почти на каждом собеседовании и быстро показывают, понимает ли кандидат цену своего кода.
Перед этой темой: Типы и память, Язык C#.
Какие коллекции есть в .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")); // TrueConsole.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 — ключи по порядку- Почему
LinkedList<T>редко быстрееList<T>? — Поиск узла O(n), узлы разбросаны по куче, нет локальности. - Чем
SortedListотличается отSortedDictionary? — Массивы против дерева: меньше памяти, но вставка O(n). - В каком порядке перебирается
Dictionary? — Порядок не гарантирован; нужен порядок —SortedDictionaryилиOrderedDictionary<TKey, TValue>. - Когда
HashSet<T>лучшеList<T>.Contains? — При частых проверках принадлежности: O(1) против O(n). - Какой тип принимать в параметре метода? — Самый узкий достаточный интерфейс:
IEnumerable<T>,IReadOnlyCollection<T>,IReadOnlyList<T>.
Как растёт List<T> и что такое Capacity?
Заголовок раздела «Как растёт List<T> и что такое Capacity?»Что это
Count— сколько элементов в списке.Capacity— длина внутреннего массива, всегда не меньшеCount.
Зачем
Массив нельзя удлинить. Поэтому List<T> держит запас места и расширяется, когда запас кончился.
Как пользоваться
- Пока
CountменьшеCapacity,Addпросто записывает элемент в массив. - Когда массив заполнен, список создаёт новый, примерно вдвое больше, и копирует элементы.
- Если размер известен, передайте его в конструктор:
new List<Order>(orders.Length). Clear()обнуляетCount, но оставляетCapacity. Сжать массив можно черезTrimExcess().
Частая ошибка
Считать Capacity лимитом размера. Список вырастет сам, а Capacity лишь показывает, сколько места уже выделено.
Как устроено
new List<T>()ссылается на общий пустой массив и ничего не выделяет.- Первый
Addсоздаёт массив на 4 элемента. - Каждое следующее расширение удваивает длину: 8, 16, 32 и так далее.
- Если сразу нужно больше (
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- Какой будет
Capacity, если добавить 1025 элементов без начальной ёмкости? — 2048: почти половина массива пустует. - Уменьшается ли
CapacityпослеRemoveилиClear? — Нет, только послеTrimExcessили явного присваиванияCapacity. - Почему
Insert(0, x)медленный? — Сдвигает весь массив, O(n); для очереди естьQueue<T>. - Сколько выделений делает
new List<T>(array)? — Одно: дляICollection<T>известенCount, элементы копируются черезCopyTo. - Чем опасен
CollectionsMarshal.AsSpan? — Если список вырастет, span продолжит смотреть на старый массив.
Что такое отложенное выполнение в LINQ и чем опасно повторное перечисление?
Заголовок раздела «Что такое отложенное выполнение в LINQ и чем опасно повторное перечисление?»Что это
Запрос LINQ — рецепт, а не результат. Пока его не перебрали, ни один элемент не прочитан.
Зачем
Запрос можно собрать по частям и не тратить память на промежуточные списки.
Как пользоваться
| Вид метода | Примеры | Когда выполняется |
|---|---|---|
| Отложенный | Where, Select, OrderBy, Take |
при переборе результата |
| Немедленный | Count(), First(), Sum(), Any() |
сразу при вызове |
| Материализующий | ToList(), ToArray(), ToDictionary() |
сразу; результат сохраняется |
Частая ошибка
Вызвать Count(), а потом foreach по тому же запросу. Запрос выполнится дважды. Если результат нужен несколько раз, сохраните его через ToList().
Как устроено
- Отложенный оператор возвращает объект, который помнит источник и лямбду.
foreachзапрашивает элементы по одному, и каждый оператор цепочки обрабатывает их по очереди.- Потоковые операторы (
Where,Select,Take) отдают элемент сразу. - Непотоковые (
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 — снимок не меняется- В какой момент выполняется
OrderBy? — При первомMoveNext: читает весь источник и сортирует. - Чем
ToList()отличается отAsEnumerable()? — Первый выполняет запрос и сохраняет результат, второй ничего не выполняет и лишь меняет статический тип. - Что будет, если вернуть запрос EF Core из метода с
using var db? — Перебор послеDisposeконтекста броситObjectDisposedException. - Почему исключение из
Selectвылетает не там, где написанSelect? — Лямбда выполняется при переборе. - Как не дать вызывающему коду дважды выполнить дорогой запрос? — Вернуть материализованную коллекцию, например
IReadOnlyList<T>.
Как устроен 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: он генерирует оба метода сам.
Как устроено
GetHashCodeключа даёт число — хеш-код.- Остаток от деления хеша на размер таблицы выбирает корзину.
- Словарь проходит цепочку записей этой корзины: сравнивает сохранённые хеши, при совпадении вызывает
Equals. - Сравнивает компаратор
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);- Что будет, если переопределить
Equals, но неGetHashCode? — Равные объекты попадут в разные корзины и не найдутся; компилятор выдаст CS0659. - Можно ли вернуть константу из
GetHashCode? — Корректно, но все ключи окажутся в одной цепочке: поиск O(n). - Почему порядок перебора словаря не гарантирован? — Новые записи занимают слоты удалённых.
- Чем
ContainsKeyплюс индексатор хужеTryGetValue? — Два поиска вместо одного (CA1854). - Как сделать словарь с ключами без учёта регистра? — Передать
StringComparer.OrdinalIgnoreCaseв конструктор.
Чем IEnumerable<T> отличается от IQueryable<T>?
Заголовок раздела «Чем IEnumerable<T> отличается от IQueryable<T>?»Что это
IEnumerable<T>— «умею перебирать элементы»: списки, массивы, результатyield return.IQueryable<T>— «умею собрать запрос к источнику».DbSet<User>в EF Core реализуетIQueryable<User>.
Зачем
Чтобы фильтровать данные там, где они лежат. db.Users.Where(u => u.IsActive) станет SQL с WHERE, и из базы придут только активные пользователи.
Как пользоваться
- Стройте запрос на
IQueryable<T>:Where,OrderBy,Select. - Выполните его в конце:
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>>, а в память переходите явно и как можно позже.
Под капотом
- Компилятор строит дерево вызовами
Expression.Lambda,Expression.Propertyпрямо в месте запроса — это аллокации при каждом выполнении. Queryable.Whereдобавляет узел вызова метода и вызываетProvider.CreateQuery.- При переборе 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 — провайдер о фильтре не знаетusing System.Linq.Expressions;
// db — DbContext с DbSet<User> Users// 1. Фильтр в SQL: SELECT ... WHERE IsActivevar active = await db.Users.Where(u => u.IsActive).ToListAsync();
// 2. Фильтр в памяти: из базы читается вся таблицаvar slow = db.Users.AsEnumerable().Where(u => u.IsActive).ToList();
// 3. Та же ловушка, но незаметная: Func выбирает Enumerable.WhereList<User> Find(Func<User, bool> filter) => db.Users.Where(filter).ToList();
// 4. Правильно: Expression оставляет запрос IQueryableList<User> FindQ(Expression<Func<User, bool>> filter) => db.Users.Where(filter).ToList();
Console.WriteLine(db.Users.Where(u => u.IsActive).ToQueryString()); // текст SQL- Что выполнится на сервере в
db.Users.AsEnumerable().Where(...)? — ТолькоSELECTвсей таблицы, фильтр — в памяти. - Почему метод с параметром
Func<T, bool>загружает всю таблицу? —Funcне дерево выражений, поэтому выбираетсяEnumerable.Where. - Что EF Core делает с локальной переменной в лямбде? — Превращает её в параметр SQL, и план запроса переиспользуется.
- Как посмотреть сгенерированный SQL? —
ToQueryString()или логирование черезLogTo. - Стоит ли возвращать
IQueryable<T>из репозитория? — Гибко, но протекают детали хранилища и время жизни контекста.
Как работает yield return и что генерирует компилятор?
Заголовок раздела «Как работает yield return и что генерирует компилятор?»Что это
Итератор — метод, который возвращает IEnumerable<T> и выдаёт элементы через yield return. yield break досрочно завершает последовательность.
Зачем
- Не нужен промежуточный список: элементы создаются по запросу.
- Можно описать бесконечную последовательность и взять только начало через
Take.
Как пользоваться
- Вызов метода лишь создаёт объект-последовательность.
foreachзапускает тело метода до первогоyield returnи получает элемент.- Следующий шаг цикла продолжает тело с места остановки.
- Новый
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"); }}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();}- Почему проверка аргумента в итераторе не срабатывает при вызове? — Тело запускается только при первом
MoveNext; валидацию выносят в обёртку. - Выполнится ли
finally, если выйти изforeachчерезbreak? — Да:foreachвызываетDispose, а он запускаетfinally. - Можно ли писать
yield returnвнутриtryсcatch? — Нет, только внутриtryсfinally. - Сколько раз выполнится тело итератора при двух
foreach? — Дважды: каждый перебор начинается с начала. - Чем
IAsyncEnumerable<T>отличается отTask<List<T>>? — Отдаёт элементы по мере готовности, а не все сразу в конце.
Чем отличаются 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 4Console.WriteLine(frozen.Contains(2)); // True- Можно ли изменить список, отданный как
IReadOnlyList<T>? — Да, приведением кList<T>; защищаетReadOnlyCollection<T>или копия. - Почему
ImmutableList<T>медленнееList<T>в циклеfor? — Индексатор спускается по дереву, O(log n). - Когда
FrozenDictionaryне стоит использовать? — Если коллекцию часто пересоздают: построение дорогое. - Как эффективно собрать immutable-коллекцию из многих элементов? — Через
BuilderилиCreateRange, а не цепочкойAdd. - Что такое
default(ImmutableArray<T>)? — Неинициализированная структура:IsDefaultвернётtrue, перебор упадёт.
Когда LINQ медленный и как это измерить?
Заголовок раздела «Когда LINQ медленный и как это измерить?»Что это
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 (многократное перечисление).
Правило
Переписывайте только измеренное:
- Вынесите код в класс с методами
[Benchmark]и атрибутом[MemoryDiagnoser]. - Запустите в Release без отладчика.
- Сравните 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: BenchmarkDotNetusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running;
BenchmarkRunner.Run<LinqVsLoop>();
[MemoryDiagnoser] // добавит колонку Allocatedpublic 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}- Перебирает ли
Count()элементыList<T>? — Нет, берётCountчерезICollection<T>, но CA1829 всё равно советует свойство. - Чем
static-лямбда помогает производительности? — Гарантирует отсутствие захвата: делегат кешируется, замыкание не создаётся. - Что показывает колонка Allocated в BenchmarkDotNet? — Байты в управляемой куче на одну операцию при
[MemoryDiagnoser]. - Чем заменить
GroupBy(...).Select(g => g.Count())для подсчёта? —CountByиз .NET 9: без промежуточных групп. - Почему бенчмарк на .NET 8 может не совпасть с .NET 10? — Новые оптимизации LINQ и JIT: девиртуализация, размещение на стеке.