Колекції та LINQ
Яку колекцію обрати, що відбувається всередині List<T> і Dictionary, коли насправді виконується запит LINQ і чим IQueryable<T> небезпечний в EF Core. Ці питання звучать майже на кожній співбесіді й швидко показують, чи розуміє кандидат ціну свого коду.
Перед цією темою: Типи й пам’ять, Мова C#.
Які колекції є в .NET і як обрати потрібну?
Section titled “Які колекції є в .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?
Section titled “Як зростає 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 і чим небезпечне повторне перелічення?
Section titled “Що таке відкладене виконання в 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?
Section titled “Як влаштований 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>?
Section titled “Чим 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 і що генерує компілятор?
Section titled “Як працює 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-колекції?
Section titled “Чим відрізняються 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 повільний і як це виміряти?
Section titled “Коли 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 усе одно радить властивість. - Чим статична лямбда допомагає продуктивності? — Гарантує відсутність захоплення: делегат кешується, замикання не створюється.
- Що показує колонка Allocated у BenchmarkDotNet? — Байти в керованій купі на одну операцію за
[MemoryDiagnoser]. - Чим замінити
GroupBy(...).Select(g => g.Count())для підрахунку? —CountByз .NET 9: без проміжних груп. - Чому бенчмарк на .NET 8 може не збігтися з .NET 10? — Нові оптимізації LINQ і JIT: девіртуалізація, розміщення на стеку.