Перейти до вмісту

Колекції та LINQ

Яку колекцію обрати, що відбувається всередині List<T> і Dictionary, коли насправді виконується запит LINQ і чим IQueryable<T> небезпечний в EF Core. Ці питання звучать майже на кожній співбесіді й швидко показують, чи розуміє кандидат ціну свого коду.

Перед цією темою: Типи й пам’ять, Мова C#.

Junior+

Які колекції є в .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")); // 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+

Як зростає List<T> і що таке Capacity?

Section titled “Як зростає List<T> і що таке Capacity?”
Відповідь

Що це

  • 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 і чим небезпечне повторне перелічення?

Section titled “Що таке відкладене виконання в 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?

Section titled “Як влаштований 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> відрізняється від 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, і з бази прийдуть лише активні користувачі.

Як користуватися

  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 і що генерує компілятор?

Section titled “Як працює 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-колекції?

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 4
Console.WriteLine(frozen.Contains(2)); // True
Senior+

Коли 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 (багаторазове перелічення).

Правило

Переписуйте лише виміряне:

  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
}