Введение: Знакомьтесь, это — ошибка, которая ломала код миллионы раз
Вы когда-нибудь писали простой foreach, запускали программу, и вдруг — бац! — вылетает InvalidOperationException с пугающим сообщением: "Collection was modified; enumeration operation may not execute"? Если да, то вы не одиноки. Этот вопрос на Stack Overflow собрал тысячи просмотров, и его задают как новички, так и опытные разработчики.
В этой статье мы разберём, почему возникает эта ошибка, как её воспроизвести, и самое главное — как её избежать. Мы рассмотрим три проверенных способа, напишем реальный код на C# и дадим вам чёткий план действий. Поехали!
Что такое "Collection was modified"? Разбор ошибки
Ошибка Collection was modified; enumeration operation may not execute — это исключение типа InvalidOperationException, которое выбрасывается, когда вы пытаетесь изменить коллекцию (добавить, удалить или заменить элемент) во время её перебора с помощью foreach или другого перечислителя (IEnumerator).
Представьте, что вы читаете книгу, а кто-то одновременно вырывает из неё страницы. Вы теряетесь, не понимаете, что читать дальше. Вот так же и перечислитель: он не знает, как продолжать, если коллекция меняется на ходу.
Как выглядит код, вызывающий ошибку?
Вот классический пример, который гарантированно приведёт к ошибке:
List<string> fruits = new List<string> { "apple", "banana", "cherry", "date" };
foreach (var fruit in fruits)
{
if (fruit == "banana")
{
fruits.Remove(fruit); // Ошибка! Collection was modified
}
}
Запустите этот код — и получите InvalidOperationException на второй итерации. Почему? Потому что foreach использует внутренний перечислитель, который блокирует изменения коллекции во время обхода.
Почему это происходит? Заглянем под капот C#
Когда вы пишете foreach, компилятор C# преобразует его в вызов метода GetEnumerator(). Этот метод возвращает объект IEnumerator, который хранит состояние обхода (текущий индекс, версию коллекции). Каждый раз, когда коллекция изменяется (через Add, Remove, Clear и т.д.), её внутренний счётчик версии увеличивается. Перечислитель проверяет эту версию на каждом шаге. Если она не совпадает — выбрасывается исключение.
Это сделано для защиты от неопределённого поведения. Представьте, что вы удаляете элемент из середины списка: индексы всех последующих элементов сдвигаются, и перечислитель может пропустить элемент или дважды обработать один и тот же. Разработчики .NET решили, что лучше выбросить исключение, чем допустить незаметную логическую ошибку.
Топ-3 способа избежать ошибки "Collection was modified"
Теперь, когда мы знаем врага в лицо, давайте разберём, как его победить. Есть три основных подхода, и каждый подходит для разных ситуаций.
Способ 1: Копирование коллекции перед перебором
Самый простой и безопасный метод — создать копию коллекции и перебирать её, а изменения вносить в оригинал.
List<string> fruits = new List<string> { "apple", "banana", "cherry", "date" };
// Создаём копию списка для перебора
foreach (var fruit in fruits.ToList()) // или new List<string>(fruits)
{
if (fruit == "banana")
{
fruits.Remove(fruit); // Теперь это безопасно
}
}
Console.WriteLine(string.Join(", ", fruits)); // Вывод: apple, cherry, date
Плюсы: Простота, читаемость, подходит для небольших коллекций.
Минусы: Требует дополнительной памяти, может быть медленным для больших списков.
Совет: Метод ToList() создаёт копию, но если у вас массив (Array), используйте ToArray().
Способ 2: Использование цикла for с обратным обходом
Если вы знаете индексы элементов, можно использовать классический for и идти с конца к началу. Это позволяет удалять элементы без смещения индексов.
List<string> fruits = new List<string> { "apple", "banana", "cherry", "date" };
// Идём с конца, чтобы удаление не влияло на индексы
for (int i = fruits.Count - 1; i >= 0; i--)
{
if (fruits[i] == "banana")
{
fruits.RemoveAt(i); // Безопасно
}
}
Console.WriteLine(string.Join(", ", fruits)); // Вывод: apple, cherry, date
Плюсы: Эффективно по памяти, не создаёт копий.
Минусы: Менее читаемо, подходит только для списков с индексами (не для HashSet или Dictionary).
Важно: Этот метод работает только для коллекций, где удаление по индексу не ломает логику (например, List<T>). Для Dictionary или HashSet используйте другие способы.
Способ 3: Использование потокобезопасных коллекций (ConcurrentBag, ConcurrentDictionary)
Если вы работаете в многопоточной среде или хотите быть уверены в безопасности, используйте коллекции из пространства имён System.Collections.Concurrent.
using System.Collections.Concurrent;
ConcurrentBag<string> fruits = new ConcurrentBag<string> { "apple", "banana", "cherry", "date" };
// ConcurrentBag не выбрасывает исключение при изменении во время перебора
foreach (var fruit in fruits)
{
if (fruit == "banana")
{
// Попробуем удалить (но ConcurrentBag не гарантирует точное удаление)
fruits.TryTake(out _); // Удаляет произвольный элемент, не обязательно banana
}
}
// Лучше использовать ConcurrentDictionary, если нужен контроль
ConcurrentDictionary<int, string> dict = new ConcurrentDictionary<int, string>();
dict.TryAdd(1, "apple");
dict.TryAdd(2, "banana");
foreach (var kvp in dict)
{
if (kvp.Value == "banana")
{
dict.TryRemove(kvp.Key, out _); // Безопасно
}
}
Плюсы: Идеально для многопоточности, не блокирует перебор.
Минусы: Специфичны, не всегда подходят для простых задач, могут быть медленнее обычных коллекций.
Совет: Если вы не пишете многопоточный код, лучше использовать первые два способа — они проще и быстрее.
Дополнительные кейсы: когда ещё может возникнуть ошибка?
Ошибка "Collection was modified" появляется не только при foreach с List. Вот