JavaRush /Курсы /Kotlin SELF /Обход List: indices, lastIndex, withIndex() и выбор стиля...

Обход List: indices, lastIndex, withIndex() и выбор стиля

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

1. Индексы в List: когда они нужны

Когда вы впервые узнаёте, что у List есть доступ по индексу, хочется писать примерно так: «я же программист, значит, буду ходить по индексам». Понимаю. У меня тоже было это чувство — как будто иначе IDE обидится. Но в Kotlin индексный обход — это инструмент, а не образ жизни.

List — это упорядоченная коллекция: элементы хранятся в определённом порядке, и к ним можно обратиться по позиции. Индексы начинаются с 0, а последний допустимый индекс — это lastIndex, который равен size - 1. Это даёт нам два режима мышления: «обход по значениям» (чаще всего проще) и «обход по позициям» (нужен только когда позиция действительно участвует в логике).

Давайте сразу договоримся о практическом правиле: если вам не нужен индекс — не берите индекс. Код станет короче, спокойнее и меньше будет пытаться упасть.

fun main() {
    val products = listOf("milk", "bread", "tea")

    for (p in products) {
        println(p)
    }
    // milk
    // bread
    // tea
}

Обход по значениям — «режим по умолчанию»

В Kotlin разработчики языка явно намекают: «если можно написать просто — напиши просто». И for (item in list) как раз тот случай. Такой обход работает, потому что List — это Iterable, а значит, у него есть итератор, который for получает неявно.

Этот стиль хорош тем, что в нём почти невозможно ошибиться с границами. Никаких i + 1, никаких size - 1, никаких «ой, я случайно сделал <= вместо <». Вы идёте по элементам, и точка.

Допустим, у нас в практическом мини‑приложении есть список задач (пока без классов, просто строки):

fun main() {
    val tasks = listOf("pay rent", "buy tea", "learn Kotlin")

    for (task in tasks) {
        println("TODO: $task")
    }
    // TODO: pay rent
    // TODO: buy tea
    // TODO: learn Kotlin
}

Читается идеально. Но иногда нам всё-таки нужен индекс: например, чтобы вывести «1. … 2. … 3. …», или чтобы обратиться к «соседнему» элементу, или чтобы удалить по номеру (удаление — отдельная история, но нумерация нам нужна уже сейчас). Тогда и появляются indices, lastIndex и withIndex().

size, lastIndex и пустой список: где начинаются ошибки на границах

У списков есть две величины, которые новички постоянно путают: size и lastIndex. Обычно это выглядит так: «раз размер 3, значит индексы 0..3». И вот тут Kotlin, как строгий преподаватель, достаёт журнал и ставит вам IndexOutOfBoundsException.

Правильная картина мира такая: size — это количество элементов, а lastIndex — последний допустимый индекс, и он равен size - 1. То есть если элементов 3, индексы будут 0, 1, 2.

Особенно интересно становится, когда список пустой. Тогда size = 0, а lastIndex = -1. Это звучит странно («минус первый элемент?»), но на самом деле это удобно: многие диапазоны в Kotlin при таких границах становятся пустыми, и цикл просто не выполняется — без дополнительной проверки.

Вот мини-таблица, которую полезно держать в голове (не как мантру, а как «шпаргалку здравого смысла»):

Список size lastIndex Допустимые индексы
["a", "b", "c"]
3
2
0..2
["only"]
1
0
0..0
[]
0
-1
нет

А теперь покажем типичную ошибку «перепутал size и lastIndex»:

fun main() {
    val items = listOf("A", "B", "C")

    // ПЛОХО: i пойдёт 0,1,2,3 — а индекс 3 уже не существует
    for (i in 0..items.size) {
        println(items[i])
    }
}

Этот код упадёт на i == 3, потому что items[3] не существует. Решение — использовать корректные границы, и вот тут как раз появляется indices.

indices: безопасные границы для обхода по индексам

Когда вы обходите список по индексам, ваша реальная задача — выбрать диапазон допустимых индексов. Kotlin даёт вам готовое решение: свойство indices, которое возвращает диапазон корректных индексов, и его можно сразу использовать в for.

Ключевое удобство indices в том, что оно автоматически корректно работает и для пустого списка, и для списка любого размера. Вы не думаете про 0..lastIndex, вы просто говорите: «пройдись по допустимым индексам».

fun main() {
    val items = listOf("milk", "bread", "tea")

    for (i in items.indices) {
        println("$i -> ${items[i]}")
    }
    // 0 -> milk
    // 1 -> bread
    // 2 -> tea
}

Здесь важный момент: если вы уже пишете items[i], вы обязаны быть уверены, что i корректен. indices как раз и означает: «я гарантирую корректность».

Этот стиль нужен, когда индекс участвует в логике. Например, вы хотите посмотреть «текущий и следующий элемент» (простейший пример — построить пары). Здесь без индекса уже сложнее:

fun main() {
    val items = listOf("A", "B", "C")

    for (i in 0 until items.lastIndex) {
        val left = items[i]
        val right = items[i + 1]
        println("$left + $right")
    }
    // A + B
    // B + C
}

Обратите внимание: тут я использовал items.lastIndex как границу (иначе i + 1 может выйти за пределы). Сам факт, что вы пишете i + 1, — уже сигнал: «в этом цикле нужно думать о границах». Это нормальная цена за более сложную логику.

Если вам «страшно» от until (или вы его ещё плохо чувствуете), можно написать так, но аккуратно:

fun main() {
    val items = listOf("A", "B", "C")

    for (i in items.indices) {
        if (i == items.lastIndex) continue
        println("${items[i]} + ${items[i + 1]}")
    }
    // A + B
    // B + C
}

Да, тут мы добавили if, но зато держимся в рамках indices и явно защищаемся от последнего элемента.

2. Индекс и номер: не путайте внутреннее и внешнее

Номер для человека и индекс для Kotlin — разные сущности

Когда вы делаете консольное приложение, очень быстро выясняется: пользователи (и преподаватели, проверяющие ваши работы) любят нумерацию с единицы. Kotlin, как типичный инженер, начинает с нуля. И если вы не договоритесь с собой, кто здесь главный, вы будете постоянно удалять «не ту задачу».

Смысл простой: индекс — это внутренний адрес элемента в списке (0..lastIndex), а номер — это внешний «человекочитаемый» идентификатор (1..size). Почти всегда, если вы печатаете список, вы печатаете номер, а не индекс.

Вот «индексный» вывод, который выглядит чуть грустно:

fun main() {
    val tasks = listOf("pay rent", "buy tea")

    for (i in tasks.indices) {
        println("$i. ${tasks[i]}")
    }
    // 0. pay rent
    // 1. buy tea
}

А вот «человеческий» вывод — просто добавляем + 1:

fun main() {
    val tasks = listOf("pay rent", "buy tea")

    for (i in tasks.indices) {
        println("${i + 1}. ${tasks[i]}")
    }
    // 1. pay rent
    // 2. buy tea
}

Это выглядит как мелочь, но для команд вроде remove 2 это вопрос выживания программы без скандала. Запомните как правило: ввод «номер», хранение «индекс», преобразование «номер - 1».

withIndex(): индекс + значение без ручного list[i]

Цикл по indices рабочий, но у него есть одна маленькая проблема: вы всё время видите tasks[i], и это превращает код в «много квадратных скобок». В Kotlin для более читаемого обхода «индекс + значение» есть withIndex() — стандартная функция, которая позволяет перебирать пары «индекс и элемент» прямо в for.

Самый приятный бонус withIndex() — вы можете использовать деконструкцию: for ((index, value) in list.withIndex()). И код становится разговорным: «для каждого index и value…».

fun main() {
    val tasks = listOf("pay rent", "buy tea", "learn Kotlin")

    for ((index, task) in tasks.withIndex()) {
        println("${index + 1}. $task")
    }
    // 1. pay rent
    // 2. buy tea
    // 3. learn Kotlin
}

Технически withIndex() отдаёт вам объект, который можно итерировать, а внутри лежат элементы с индексами. Но вам сейчас важнее практика: этот стиль читабельнее, когда вам нужно и то, и другое.

Отдельно замечу: withIndex() особенно приятно смотрится, когда имена осмысленные. Если вы пишете for ((i, x) in ...) — это ещё терпимо. Но если вы пишете for ((a, b) in ...) и потом делаете a + 1 и печатаете b, то человек рядом начнёт подозревать, что вы шифруете послание внеземной цивилизации.

3. Как выбрать стиль обхода

Когда появляется три способа обхода, новичок начинает думать, что нужно выбрать «самый правильный» раз и навсегда. Успокою: единственного правильного нет. Есть подходящий по ситуации. Хорошая новость в том, что критерии выбора очень простые, и их можно превратить в мини-алгоритм для мозга.

Представьте, что вы пишете код и задаёте себе один вопрос: «Мне нужен индекс?». Если нет — вы обходите по значениям. Если да — задаёте второй: «Мне нужен индекс ради доступа к соседям/позициям, или просто ради печати номера?». Если просто печать — чаще всего удобнее withIndex(). Если именно позиционная логика — берите indices.

Вот это можно изобразить такой блок-схемой:

flowchart TD
    A["Надо пройти по List"] --> B{"Нужен индекс?"}
    B -->|Нет| C["for (x in list)"]
    B -->|Да| D{"Зачем индекс?"}
    D -->|Только номер/печать| E["for ((i, x) in list.withIndex())"]
    D -->|Позиционная логика: соседи, i+1, i-1| F["for (i in list.indices) { ... list[i] ... }"]

Смысл не в том, чтобы поклоняться схеме, а в том, чтобы не делать «индексный цикл по привычке». Kotlin очень хорошо читается, когда вы пишете минимально необходимое.

4. Мини‑проект: команда list в Task CLI

Сейчас мы аккуратно применим новый материал в нашем маленьком приложении. Пусть это будет простейший «Task CLI»: мы храним задачи в MutableList<String>, добавляем команды, и самая важная команда на этом этапе — красиво показать список. Если список показывается криво, пользователю потом нечего вводить в remove, и он обижается (а обиженный пользователь страшнее компилятора).

Начнём с функции печати. Обратите внимание: я принимаю List<String>, а не MutableList<String>, потому что печать не должна менять список. Это хорошая привычка: «бери самый узкий тип, который нужен для задачи».

fun printTasks(tasks: List<String>) {
    if (tasks.isEmpty()) {
        println("Список задач пуст.")
        return
    }

    for ((index, task) in tasks.withIndex()) {
        println("${index + 1}. $task")
    }
}

Теперь пример main, который демонстрирует работу:

fun main() {
    val tasks = mutableListOf<String>()
    tasks.add("pay rent")
    tasks.add("buy tea")

    printTasks(tasks)
    // 1. pay rent
    // 2. buy tea
}

А теперь маленькая, но важная деталь: иногда вы хотите печатать не только задачу, но и какую-то служебную информацию, связанную с индексом. Например, подсказку «внутренний индекс» (для отладки) или формат «#0001». Для такого формата withIndex() тоже идеален — вы всё равно уже получаете индекс.

fun main() {
    val tasks = listOf("pay rent", "buy tea")

    for ((index, task) in tasks.withIndex()) {
        val debugIndex = index
        println("[$debugIndex] ${index + 1}. $task")
    }
    // [0] 1. pay rent
    // [1] 2. buy tea
}

В реальном приложении вы, конечно, не будете печатать [0], но как учебный «рентген» — полезно: вы буквально видите разницу «индекс vs номер».

5. Типичные ошибки при обходе List

Ошибка №1: использовать size как последний индекс.
Это самая частая и самая обидная ошибка, потому что выглядит логично: «раз элементов size, значит последний индекс size». Но нет: индексы начинаются с 0, а значит последний допустимый индекс — lastIndex, то есть size - 1. Исправление простое: в индексных циклах используйте indices или явно lastIndex, а не size.

Ошибка №2: печатать пользователю индексы, а принимать от него номера (или наоборот).
Если вы выводите 0. …, а пользователь вводит remove 1, вы неизбежно получите «не тот элемент» и кучу путаницы. Решение — договориться: пользователю печатаем нумерацию с 1, внутри программы работаем индексами с 0. И всегда делаем преобразование index = number - 1.

Ошибка №3: брать индексный обход «на автомате», хотя индекс нигде не нужен.
Код вида for (i in tasks.indices) println(tasks[i]) не сломан, но он тяжелее читается, чем for (task in tasks) println(task). Если индекс не участвует в логике, лучше обходить по значениям: меньше шансов случайно добавить «опасную математику индексов» и получить ошибку на границе.

Ошибка №4: пытаться в withIndex() писать слишком много логики и терять смысл.
withIndex() хорош, когда вы печатаете «номер + значение» или делаете маленькую привязку к позиции. Но если внутри цикла у вас начинается сложная арифметика, проверки соседей и несколько временных переменных, часто проще перейти к indices, где вы явно управляете доступом list[i] и можете аккуратно обрабатывать i - 1 и i + 1.

Ошибка №5: не думать о пустом списке в пользовательском выводе.
Технически for (i in list.indices) на пустом списке просто не выполнится, и это безопасно. Но UX получается странный: пользователь ввёл list, а программа «молча ничего не сказала». Поэтому для командного приложения лучше явно обрабатывать isEmpty() и печатать понятное сообщение вроде «Список пуст».

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