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() і друкувати зрозуміле повідомлення на кшталт «Список порожній».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ