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.
- Для словарей, множеств и других сложных коллекций — сначала собирайте элементы на удаление, потом проходите по этому списку и удаляйте из исходной коллекции.
- Если не уверены — подумайте: как меняется коллекция при удалении? Как поведёт себя итератор? Если есть малейшее сомнение, значит, способ выбран не тот.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ