Collection was modified: как победить ошибку перебора коллекций

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Знакомьтесь, это — ошибка, которая ломала код миллионы раз

Вы когда-нибудь писали простой 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. Вот

Блоги

Рекомендаций по книгам