1. Вступ
Майже кожен, хто починає програмувати на C#, рано чи пізно стикається з однаковою проблемою: є колекція (наприклад, список об’єктів), і потрібно видалити з неї зайві елементи за певною умовою. Здається просто, і рука сама тягнеться до звичного, зручного циклу foreach, адже це «найбезпечніший» і «дружній» спосіб перебору. Та раптом, у найнесподіваніший момент, зʼявляється загадкова помилка часу виконання, якої не було на простих прикладах, — і робота програми зупиняється на рівному місці.
Розберімося, чому так відбувається, що відбувається «під капотом» у колекціях та ітераторах, і як видаляти елементи грамотно, щоб уникати сюрпризів і помилок.
Чому foreach не сумісний із видаленням елементів
Щоб краще уявити, що відбувається, уявіть чергу людей (це наша колекція). Ви рухаєтеся чергою й питаєте кожного: «Залишити чи викреслити?» Якщо почати викреслювати когось прямо під час перебору, уся черга зсувається, люди переміщуються, і план «наступна людина — наступна у списку» миттєво ламається. Можливо, хтось залишиться не опитаним або когось ви запитаєте двічі.
Приклад на C#:
List<string> names = new List<string> { "Антон", "Борис", "Віка", "Гріша" };
foreach (string name in names)
{
if (name.StartsWith("В"))
names.Remove(name); // Бум! InvalidOperationException
}
Коли програма дійде до "Віка" і вирішить її видалити, внутрішній ітератор втратить «контакт із реальністю» — і ви отримаєте повідомлення:
InvalidOperationException: Collection was modified; enumeration operation may not execute.
Це не просто примха — так C# захищає вас від важковідтворюваних помилок і пошкодження структури даних.
2. Чому такий простий код не працює?
Як усе влаштовано всередині?
Коли ви пишете цикл foreach, компілятор генерує спеціальний об’єкт — ітератор (IEnumerator), який відстежує поточну позицію в колекції. Цей об’єкт пам’ятає, скільки елементів було на старті, який елемент зараз «активний», і суворо контролює, щоб колекцію не змінювали під час перебору.
Будь-яка спроба видалити або додати елемент у межах foreach порушує цей контракт. Чому? Якщо після видалення елементів індекси зсунулися, ітератор уже не зможе коректно перейти до наступного елемента. Хтось може бути пропущений, когось врахують двічі — у підсумку вийде безлад. Тому за першої ж зміни колекції .NET чесно й чітко викидає виняток.
До чого призводить видалення напряму
Уявімо, що ми написали таку програму:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6 };
foreach (int x in numbers)
{
if (x % 2 == 0)
numbers.Remove(x);
}
Здавалося б, усе логічно: перебрати всі числа й видалити парні. Але на другому проході програма викине помилку — «колекцію змінено під час ітерації».
Іноді виникає спокуса обійти це обмеження й спробувати «на свій страх і ризик». Але навіть якби помилки не було, залежно від структури колекції результат був би непередбачуваним. Наприклад, ви могли б випадково «перестрибнути» через деякі елементи або видалити не все, що треба.
3. Як же правильно?
Техніка №1: Зворотний цикл for
Річ у тім, що під час видалення елемент зсуває наступні елементи ліворуч, і якщо йти з початку списку, індекси легко переплутати й пропустити елементи. Щоб цього уникнути, розумно йти з кінця.
List<string> names = new List<string> { "Антон", "Борис", "Віка", "Гріша" };
for (int i = names.Count - 1; i >= 0; i--)
{
if (names[i].StartsWith("В"))
names.RemoveAt(i);
}
У цьому прикладі після кожного видалення всі елементи, що стоять після видаленого, зсуваються, але індекси, які ми ще не обробили, не порушуються. У результаті нічого не буде пропущено.
Техніка №2: Відфільтрувати й створити новий список
Іноді простіше (і часто швидше) перебрати колекцію, зібрати лише ті елементи, які мають залишитися, і замінити початковий список новим.
var names = new List<string> { "Антон", "Борис", "Віка", "Гріша" };
names = names.Where(name => !name.StartsWith("В")).ToList();
// У результаті залишаться "Антон" і "Гріша"
Цей підхід доречний, коли колекція не надто велика або не критично зберегти початкове посилання на об’єкт.
Техніка №3: Використовувати спеціальні методи колекцій
Якщо ви працюєте з класичним List<T>, то для видалення за умовою є зручний метод:
names.RemoveAll(name => name.StartsWith("В"));
Весь процес усередині буде реалізовано коректно, а ви отримаєте лаконічний і зрозумілий код.
Техніка №4: Збирати на видалення
Є колекції, які не можна змінювати «на льоту» (наприклад, Dictionary, HashSet, або навіть ваш власний клас). У таких випадках застосовують підхід «позначити на видалення»:
- Спочатку пройдіться колекцією і зберіть усі елементи, які треба видалити, в окремий список.
- Потім пройдіться цим новим списком і видаліть потрібні елементи з початкової колекції.
Dictionary<int, string> dict = new Dictionary<int, string> { [1] = "one", [2] = "two", [3] = "three" };
var toDelete = new List<int>();
foreach (var kvp in dict)
{
if (kvp.Key % 2 == 0)
toDelete.Add(kvp.Key);
}
foreach (var key in toDelete)
dict.Remove(key);
4. Корисні нюанси
Помилки й міфи початківців
Одна з найпоширеніших помилок — очікувати, що видалення елемента з колекції під час перебору спрацює «якось», адже в деяких інших мовах (наприклад, Python) це часто можливо. Але у C# це суворо заборонено саме для вашої безпеки: набагато краще отримати явний виняток, ніж тихий і підступний дефект, який потім ніхто не зможе відтворити.
Ще одна типова помилка — використовувати цикл for зі збільшенням індексу, а не зі зменшенням. Це призводить до того, що після видалення елемента всі наступні зсуваються, і частину елементів буде пропущено. Завжди йдіть з кінця до початку, якщо видаляєте за індексом.
Мораль історії
Завдання «видалити елементи з колекції за умовою» трапляється в кожній другій програмі на C#, але робити це прямо всередині циклу foreach не можна — така архітектура мови, продиктована турботою про цілісність ваших даних і відсутність неочікуваних помилок.
Запамʼятайте це правило, і воно позбавить вас безсонних ночей із налагоджувачем.
Як робити завжди правильно
- Ніколи не видаляйте елементи колекції прямо в циклі foreach. Це спричинить помилку часу виконання.
- Для списків (List<T>) і масивів використовуйте або цикл for з кінця, або методи RemoveAll і фільтрацію через LINQ.
- Для словників, множин та інших складних колекцій — спочатку збирайте елементи на видалення, потім пройдіться цим списком і видаліть їх із початкової колекції.
- Якщо не впевнені — поміркуйте: як змінюється колекція під час видалення? Як поводитиметься ітератор? Якщо є бодай найменший сумнів, спосіб обрано не той.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ