JavaRush /Курсы /Kotlin SELF /Безопасное удаление при обходе коллекций

Безопасное удаление при обходе коллекций

Kotlin SELF
20 уровень , 3 лекция
Открыта

1. Почему удаление во время обхода часто ломает программу

Представьте, что вы идёте по длинному коридору и считаете двери. Если кто-то во время вашего подсчёта внезапно сдвигает стены, убирает двери и переставляет их местами, вы либо собьётесь, либо начнёте пропускать двери, либо вообще ударитесь лбом (в роли лба выступит исключение). С коллекциями ровно то же самое: обход — это процесс, который предполагает, что структура данных не “пересобирается” под ногами.

Главная причина, почему удаление во время обхода опасно, в том, что механизм for (через итератор) ожидает, что коллекция не будет меняться “в обход правил”. Kotlin/JVM-коллекции часто умеют обнаруживать такие вмешательства и отвечают исключением ConcurrentModificationException. Иногда исключения нет, но появляется логическая ошибка: вы удаляете элемент, индексы сдвигаются, и следующий элемент “проскальзывает мимо проверки”.

В этой лекции нам важно запомнить практическую формулу: обход и изменение коллекции должны быть согласованы одним механизмом. Если обход идёт итератором — удалять должен итератор. Если обход идёт по индексам — удалять надо так, чтобы индексы ещё не пройденных элементов не “поехали”.

Запретный паттерн: remove(...) внутри for (x in list)

Давайте начнём с самого популярного “греха”, который выглядит логично, читаемо и… иногда работает ровно до первого реального сценария. Логика кажется железной: “я перебираю элементы, если элемент плохой — удаляю его”. Но “перебираю” и “удаляю” в этот момент используют разные внутренние механизмы, и они не договаривались.

fun main() {
    val items = mutableListOf("keep", "remove", "keep")

    for (x in items) {
        if (x == "remove") {
            items.remove(x) // опасно
        }
    }

    println(items)
}

На JVM такой код часто заканчивается ConcurrentModificationException: итератор замечает, что коллекцию модифицировали “в обход него”, и прекращает работу самым честным способом — падением. Даже если повезло и исключения не случилось, вы не должны на это рассчитывать: поведение зависит от реализации коллекции и конкретной ситуации.

Важно уловить мысль: for (x in items) использует итератор под капотом, а items.remove(x) меняет коллекцию напрямую. Это как если бы вы ехали по навигатору, а кто-то в реальном времени перекладывал дороги.

Запретный паттерн: удаление по индексам слева направо

Вторая ловушка чаще встречается у людей, которые уже поняли, что “в for (x in list) удалять нельзя”, и решили: “ладно, буду обходить по индексам — я же всё контролирую!”. Контроль действительно появляется, но вместе с ним появляется и новая проблема: удаление сдвигает элементы влево, а ваш индекс продолжает расти вправо.

Посмотрим на пример, где нужно удалить все "remove":

fun main() {
    val items = mutableListOf("keep", "remove", "remove", "keep")

    for (i in 0..items.lastIndex) {
        if (items[i] == "remove") {
            items.removeAt(i) // опасно при движении слева направо
        }
    }

    println(items)
}

Даже если этот код не упадёт из-за изменения lastIndex (а он легко может упасть на выходе за границы), есть более тонкий эффект: когда вы удаляете items[1], то бывший items[2] переезжает в позицию 1. А цикл уже готовится перейти к i = 2, и этот “переехавший” элемент вы просто не проверите. Итог — часть элементов не удалится, и вы получите баг без исключения. Самый любимый тип багов: “программа не падает, но врёт”.

Чтобы больше так не страдать, запоминаем: если удаляем по индексам, идти нужно с конца к началу.

2. Стратегия: удаление через MutableIterator.remove()

Сейчас будет момент, когда коллекции внезапно станут добрыми. У Kotlin есть специальный тип итератора для mutable-коллекций — MutableIterator. Он расширяет обычный Iterator и добавляет метод remove(), который удаляет “текущий” элемент корректно, то есть согласованно с процессом обхода.

Ключевая идея: если обход управляется итератором, то итератор и должен удалять.

Вот безопасный вариант удаления при обходе списка:

fun main() {
    val items = mutableListOf("a", "remove", "b")
    val it = items.iterator()

    while (it.hasNext()) {
        val x = it.next()
        if (x == "remove") {
            it.remove()
        }
    }

    println(items) // [a, b]
}

Обратите внимание на “ритуал”: мы сначала делаем next(), тем самым итератор “показывает пальцем” на текущий элемент, и только после этого можно вызывать remove(). Именно поэтому часто говорят: remove() удаляет последний элемент, который был возвращён next().

Итератор одноразовый

После того как вы дошли до конца, этот итератор “исчерпан”. Если вам нужно ещё раз пройтись по коллекции — создавайте новый итератор через items.iterator(). Это не ошибка и не “расточительство”, это нормальная модель обхода.

Пример из мини-приложения: чистим пустые строки

Давайте продолжим практический стиль: представим, что в проекте у нас есть список покупок, и пользователь иногда добавляет мусор (пустые строки, пробелы). Мы хотим сделать команду cleanup, которая удалит пустые записи.

fun cleanupBlankItems(items: MutableList<String>) {
    val it = items.iterator()

    while (it.hasNext()) {
        val name = it.next()
        if (name.isBlank()) {
            it.remove()
        }
    }
}

Эта функция маленькая, но полезная: она показывает именно тот случай, когда удаление происходит “по условию” во время обхода, и здесь итератор — самый надёжный инструмент.

Контракт MutableIterator.remove(): как не нарушить правила

Сама идея “удалять через итератор” звучит как спасение, но у неё есть правила. Причём правила не из серии “желательно”, а из серии “иначе будет исключение”. Если вы нарушите контракт, коллекции снова станут злыми, просто теперь уже по делу.

Самая частая проблема — вызвать remove() два раза подряд без нового next(). Покажем, как не надо:

fun main() {
    val items = mutableListOf("remove", "x")
    val it = items.iterator()

    it.next()
    it.remove()

    // it.remove() // нельзя: второго next() не было
    println(items) // [x]
}

Логика простая: после remove() итератор уже удалил “последний выданный элемент”. Чтобы снова что-то удалить, итератор должен снова выдать элемент через next(). Если попытаться удалить повторно, итератор не понимает, “что именно вы хотите удалить”, и справедливо ругается.

В реальном проекте это обычно всплывает не в таком явном виде, а когда внутри цикла появляется сложная ветвистая логика. Поэтому полезная привычка — держать тело цикла простым: “получили элемент → проверили условие → при необходимости удалили”.

3. Стратегия: удаление по индексам справа налево

Иногда итератор — не самый удобный вариант. Например, ваша логика завязана на индекс: вы удаляете “каждый третий элемент”, удаляете диапазон, или вам важно печатать номер элемента пользователю. В таких случаях проще мыслить индексами. Но тогда удалять нужно так, чтобы удаление не влияло на те элементы, которые вы ещё не проверили.

Фокус здесь в том, что у списка индексы идут от 0 до lastIndex, где lastIndex == size - 1. Если идти с конца к началу, то удаление элемента не сдвигает элементы, которые находятся левее. А значит, ваши “ещё не проверенные” элементы не меняют индекс.

Вот безопасный пример:

fun main() {
    val items = mutableListOf("keep", "remove", "remove", "keep")

    for (i in items.lastIndex downTo 0) {
        if (items[i] == "remove") {
            items.removeAt(i)
        }
    }

    println(items) // [keep, keep]
}

Мини-схема: почему справа налево работает

flowchart TD
    A[Идём i слева направо] --> B["Удаляем items[i]"]
    B --> C[Элементы справа сдвигаются влево]
    C --> D[Следующий i пропускает один элемент]
    D --> E[Логическая ошибка]

    A2[Идём i справа налево] --> B2["Удаляем items[i]"]
    B2 --> C2[Сдвигаются только элементы справа, но справа уже всё проверено]
    C2 --> D2[Непроверенные элементы слева не меняют индекс]
    D2 --> E2[Логика корректна]

4. Как выбрать стратегию в реальном коде

На практике вы будете выбирать стратегию не по красоте, а по тому, что вам нужно в конкретной операции. Чтобы не держать это всё в голове как “чувство”, удобно иметь маленькую таблицу-напоминалку.

Ситуация Лучше подходит Почему
Удаляем элементы по условию, индекс не важен
MutableIterator.remove()
Обход и удаление делают одно и то же “устройство”, меньше шансов ошибиться
Удаляем из MutableList, но логика завязана на индекс Цикл for (i in lastIndex downTo 0) + removeAt(i) Индексы непроверенных элементов не сдвигаются
Удаляем из MutableMap во время обхода Итератор по entries + remove() Удаление “текущего entry” согласовано с обходом
Удаляем один элемент “по значению” без обхода
list.remove(value)
Обход не нужен вообще, значит и конфликтов нет

Обратите внимание: таблица не говорит “всегда делай так”. Она говорит: “если вы уже оказались в ситуации X — вот самый предсказуемый путь”.

5. Безопасное удаление из MutableMap во время обхода

Map — не список, по индексам вы по нему не пройдёте. Но удалять из MutableMap во время обхода тоже иногда очень хочется. Например, в нашем приложении может быть MutableMap<String, Int> как “склад”: товар → количество. И мы хотим вычистить товары, где количество стало 0.

Если вы попытаетесь делать stock.remove(key) внутри for ((k, v) in stock), получится тот же конфликт “обход через итератор, удаление напрямую”. Правильный вариант — итератор по entries:

fun removeZeroStock(stock: MutableMap<String, Int>) {
    val it = stock.entries.iterator()

    while (it.hasNext()) {
        val entry = it.next()
        if (entry.value == 0) {
            it.remove()
        }
    }
}

Почему именно entries? Потому что Map.Entry даёт вам сразу и ключ, и значение, без повторного поиска. А итератор по entries умеет корректно удалить текущую пару. Это концептуально тот же принцип, что и со списком: удалять должен тот, кто обходит.

6. Встраиваем в приложение: операции чистки данных

В реальных программах удаление во время обхода редко существует само по себе. Обычно оно живёт внутри “операции обслуживания”: удалить некорректные записи, убрать пустые элементы, удалить завершённые задачи, вычистить нулевые остатки. Поэтому полезно оформлять такие куски как отдельные функции — маленькие и проверяемые.

Допустим, в нашем консольном приложении состояние покупок хранится так: каждая покупка — это Pair<String, Int>, где first — имя товара, second — количество. (Классы мы ещё не проходили, поэтому без data class.)

Сделаем функцию, которая удаляет покупки с количеством <= 0. Это классический случай “удаляем много элементов по условию”, поэтому итератор подходит идеально:

fun removeNonPositive(items: MutableList<Pair<String, Int>>) {
    val it = items.iterator()

    while (it.hasNext()) {
        val item = it.next()
        if (item.second <= 0) {
            it.remove()
        }
    }
}

А теперь ситуация, где удобнее индексы: допустим, мы хотим удалить все элементы с именем "tmp" (пользователь добавлял временные записи), но нам также хочется вывести, какие именно строки мы удалили, с их “номером в списке на момент удаления”. Тогда мы можем идти справа налево, и номера “правее” нас уже не интересуют:

fun removeAllTmp(items: MutableList<String>) {
    for (i in items.lastIndex downTo 0) {
        if (items[i] == "tmp") {
            items.removeAt(i)
        }
    }
}

Здесь важно не пытаться сделать “красивее” и не заменить на обход слева направо: справа налево — это не прихоть, а гарантия корректности.

remove(value) и removeAt(index): не перепутать смысл

Когда вы пишете код, который “удаляет”, очень легко начать путать: вы удаляете по значению или по позиции? Это особенно заметно в командных приложениях, где пользователь вводит remove 3 и ожидает “удалить третий элемент”, а вы случайно делаете “удалить элемент со значением "3"”.

У MutableList обычно есть две разные операции: удаление по значению и удаление по индексу. В Kotlin это выражено прямо в именах: removeAt(i) удаляет по позиции, а remove(x) — по значению. Когда вы строите команды, заранее выбирайте семантику: либо команда remove удаляет по номеру (тогда нужен removeAt и проверка i in indices), либо по значению (тогда remove(value)), либо у вас две разные команды.

Это важно именно в контексте сегодняшней темы: если вы удаляете “много элементов” по условию, почти всегда вы будете либо вызывать removeAt много раз, либо использовать итератор. А если удаляете ровно один элемент по значению — вам обход вообще не нужен.

7. Типичные ошибки при безопасных удалениях

Ошибка №1: удалять через коллекцию внутри for (x in collection).
Это выглядит естественно, но конфликтует с тем, как работает for: под капотом он использует итератор, а вы меняете структуру коллекции напрямую. Частый итог — ConcurrentModificationException, а иногда ещё хуже: “просто странное поведение”. Надёжное решение — удалять либо через MutableIterator.remove(), либо не удалять в процессе обхода вообще.

Ошибка №2: идти по индексам слева направо и делать removeAt(i) внутри цикла.
Технически вы “всё контролируете”, но удаление сдвигает элементы, и вы легко пропускаете часть коллекции или выходите за границы. Если уж выбран индексный стиль, безопасное правило одно: удаляем справа налево через for (i in lastIndex downTo 0).

Ошибка №3: вызывать it.remove() не по контракту, например дважды подряд.
MutableIterator.remove() удаляет элемент, который был возвращён последним next(). Если вы вызовете remove() до next() или повторите remove() без нового next(), итератор не сможет корректно определить цель удаления и выбросит исключение. Дисциплина простая: “next() → проверка → возможно remove()”.

Ошибка №4: пытаться удалить из Map, обходя keys, и параллельно делать map.remove(key).
Даже если кажется, что “я же удаляю по ключу, логично”, проблема та же — обход и удаление используют разные механизмы. Надёжный путь: val it = map.entries.iterator() и удаление через it.remove().

Ошибка №5: путать “индекс” и “номер для пользователя”.
Пользователь думает “первый элемент” и имеет в виду 1, а список думает “первый элемент” и имеет в виду индекс 0. Если вы принимаете команду удаления по номеру, всегда делайте index = number - 1 и проверяйте index in items.indices, иначе вы получите либо удаление не того элемента, либо падение.

1
Задача
Kotlin SELF, 20 уровень, 3 лекция
Недоступна
Уборка заметок
Уборка заметок
1
Задача
Kotlin SELF, 20 уровень, 3 лекция
Недоступна
Чистка измерений
Чистка измерений
1
Задача
Kotlin SELF, 20 уровень, 3 лекция
Недоступна
Фильтр по шагу
Фильтр по шагу
1
Задача
Kotlin SELF, 20 уровень, 3 лекция
Недоступна
Очистка склада
Очистка склада
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ