JavaRush /Курси /Kotlin SELF /Обхід Map: keys, values, entries, Map.Entry і деконструкц...

Обхід Map: keys, values, entries, Map.Entry і деконструкція (k, v)

Kotlin SELF
Рівень 20 , Лекція 2
Відкрита

1. Map обходиться не як список: ключі замість індексів

Якщо List схожий на пронумерований список справ, то Map — це радше телефонна книга: за «ім’ям» (ключем) ми шукаємо «номер» (значення). Тому спроба «пройтися мапою за індексами» звучить так само дивно, як прохання «дай мені третій контакт за рахунком», не уточнюючи, за яким саме порядком. У Kotlin Map<K, V> зберігає пари «ключ → значення». Ключі в ній унікальні, а порядок обходу не має бути основою логіки.

Найважливіша думка: Map — це не «список пар», а структура, у якій доступ до даних відбувається через ключ. Саме з цієї ідеї й випливають правильні способи обходу.

Нагадування: як створюється Map і що таке Pair

Перш ніж обходити, треба мати що обходити. Зазвичай мапу створюють через mapOf(...) або mutableMapOf(...), передаючи туди пари Pair<K, V>. Найчастіше пари створюють через інфікс-функцію to, яка виглядає як маленька стрілочка — «ключ до значення».

Наприклад:

fun main() {
    val prices = mapOf(
        "coffee" to 250,
        "tea" to 120
    )

    println(prices) // {coffee=250, tea=120}
}

Це лише стартова точка. Далі ми обходитимемо такі мапи різними способами — залежно від завдання.

Нюанс: map[key] повертає V?

Коли ви читаєте значення за ключем, ви ніби запитуєте в мапи: «А чи є в тебе такий ключ?». Якщо ключа немає, мапа не може «вгадати» значення, тому повертається null. Саме тому вираз map[key] має тип V?, навіть якщо V сам по собі не nullable.

Наживо це виглядає так:

fun main() {
    val ages = mapOf("Ann" to 20, "Bob" to 31)

    val annAge = ages["Ann"]
    val kateAge = ages["Kate"]

    println(annAge)  // 20
    println(kateAge) // null
}

І ось тут багато новачків потрапляють у пастку: починають обходити ключі, а значення дістають через map[key], і раптом натрапляють на null. У цій лекції ми розберемо способи обходу, які роблять такий код простішим, безпечнішим і читабельнішим.

2. Представлення Map: keys, values, entries

Коли ви обходите Map, зазвичай вам потрібно одне з трьох: пройтися ключами, пройтися значеннями або пройтися парами повністю. Kotlin робить це явно: у мапи є keys, values і entries. Це не три різні мапи, а «вікна» (представлення) на одну й ту саму структуру — просто з різним поглядом на дані.

Зафіксуймо це в невеликій таблиці. Вона стане вам у пригоді щоразу, коли рука потягнеться «обходити Map як List».

Що потрібно зробити Що обходити Що отримуємо в циклі
Потрібні лише ключі
map.keys
K
Потрібні лише значення
map.values
V
Потрібні ключ і значення разом
map.entries
Map.Entry<K, V>
Потрібні ключ і значення разом (короткий синтаксис)
for ((k, v) in map)
(K, V)

Обхід ключів: for (k in map.keys)

Іноді для логіки взагалі не важливі значення — головне знати набір ключів. Наприклад, ви хочете вивести список категорій у нашому навчальному консольному застосунку (умовному «трекері витрат») або принципово перевірити, чи є категорія "food".

fun main() {
    val totalsByCategory = mapOf(
        "food" to 1200,
        "transport" to 450
    )

    for (category in totalsByCategory.keys) {
        println(category)
    }
    // food
    // transport
}

Плюс цього способу в тому, що він чесно повідомляє читачеві: «Я працюю з ключами». Мінус такий: якщо вам потім усе одно знадобляться значення, ви або зробите другий прохід, або почнете діставати map[category], і знову повернеться тема V?.

Обхід значень: for (v in map.values)

Якщо вас цікавлять лише значення, можна обходити values. Наприклад, ви хочете порахувати загальну суму за всіма категоріями (поки без просунутих операцій з колекціями — просто в циклі).

fun main() {
    val totalsByCategory = mapOf(
        "food" to 1200,
        "transport" to 450
    )

    var sum = 0
    for (value in totalsByCategory.values) {
        sum += value
    }

    println("Усього = $sum") // Усього = 1650
}

Цей спосіб хороший, коли ключі справді не потрібні. І так, таке трапляється частіше, ніж здається: ключі нерідко потрібні «для красивого виводу», але не для обчислень.

Обхід пар: for (entry in map.entries)

Якщо потрібні і ключ, і значення, найпряміший шлях — обходити entries. Кожен елемент entries — це Map.Entry<K, V>, тобто об’єкт-пара з полями key і value.

fun main() {
    val totalsByCategory = mapOf(
        "food" to 1200,
        "transport" to 450
    )

    for (entry in totalsByCategory.entries) {
        println("${entry.key} -> ${entry.value}")
    }
    // food -> 1200
    // transport -> 450
}

Цей варіант зазвичай виграє в «обході ключів + map[key]», бо ви не робите повторного пошуку за ключем і не змушуєте себе мати справу з V?.

Що таке Map.Entry<K, V> і чим він відрізняється від Pair

Map.Entry — це «пакування» однієї пари всередині Map. Важливо розуміти: Entry — не обов’язково Pair. Це окремий тип, який передає сенс саме «елемент мапи». Він дає доступ до entry.key і entry.value, а головне — дозволяє Kotlin і стандартній бібліотеці будувати навколо Map передбачувані API.

Щоб закріпити, ось невеличка схема «хто з кого складається»:

flowchart TD
    M["Map⟨K, V⟩"] --> KEYS["keys: Set⟨K⟩"]
    M --> VALUES["values: Collection⟨V⟩"]
    M --> ENTRIES["entries: Set⟨Entry⟨K, V⟩⟩"]
    ENTRIES --> E["Entry⟨K, V⟩"]
    E --> K["key: K"]
    E --> V["value: V"]

Практична думка: коли ви обходите entries, вам майже ніколи не потрібно знову лізти в мапу за ключем. Ви вже «стоїте» на парі, тримаєте її обома руками й можете працювати напряму.

3. Деконструкція та вибір стилю обходу

Деконструкція: for ((k, v) in map)

Іноді запис entry.key / entry.value здається трохи багатослівним. Kotlin дозволяє «розібрати» пару на дві змінні прямо в for. Це називається деконструкцією, і у випадку Map вона виглядає дуже природно: for ((k, v) in map).

Важливо: коли ви пишете for ((k, v) in map), ви обходите саме пари, просто в компактнішому синтаксисі.

fun main() {
    val totalsByCategory = mapOf(
        "food" to 1200,
        "transport" to 450
    )

    for ((category, total) in totalsByCategory) {
        println("$category: $total")
    }
    // food: 1200
    // transport: 450
}

Цей варіант зазвичай найчитабельніший, якщо вам потрібні обидві частини пари й ви не робите всередині циклу нічого надто складного. До того ж він підштовхує давати змінним влучні імена: category, total, name, age.

Як обрати стиль обходу

Правило, яке економить багато нервів: якщо вам потрібна лише одна частина даних, обходьте лише її (keys або values). Якщо вам потрібно і те, і інше — обходьте пари (entries або деконструкція). Так код стає прямолінійнішим, а помилки — менш імовірними.

Порівняймо два підходи на одному прикладі: виведемо category -> total.

Підхід «за ключами, а значення дістану потім»:

fun main() {
    val totalsByCategory = mapOf("food" to 1200, "transport" to 450)

    for (category in totalsByCategory.keys) {
        val total = totalsByCategory[category]
        println("$category -> $total")
    }
    // food -> 1200
    // transport -> 450
}

Він працює, але тут total має тип Int?, і це трохи дратує.

Підхід «за парами»:

fun main() {
    val totalsByCategory = mapOf("food" to 1200, "transport" to 450)

    for ((category, total) in totalsByCategory) {
        println("$category -> $total")
    }
    // food -> 1200
    // transport -> 450
}

Тут total — звичайний Int, і це рівно те, що ми хочемо у 99% випадків.

4. Практика: звіт за категоріями

Давайте додамо цю тему в той самий застосунок, який ми тягнемо через курс: консольний трекер витрат. Ми поки не використовуємо ООП, тому витрати можна зберігати простим Triple<String, String, Int>: (category, title, amount). Так, це неідеально, але зараз наше завдання — тренувати колекції та обхід, а не дизайн доменної моделі.

Нижче — невеликий шматок логіки: ми вже маємо список витрат і хочемо побудувати Map «категорія → сума», а потім гарно вивести звіт. Просунуті операції з колекціями (це буде пізніше) поки не використовуємо — зробимо все «чесним» циклом.

fun buildTotalsByCategory(expenses: List<Triple<String, String, Int>>): Map<String, Int> {
    val totals = mutableMapOf<String, Int>()

    for (e in expenses) {
        val category = e.first
        val amount = e.third

        val oldTotal = totals[category] ?: 0
        totals[category] = oldTotal + amount
    }

    return totals
}

Зверніть увагу на totals[category] ?: 0. Ми читаємо з Map, отримуємо Int? і, якщо там null (категорії ще немає), вважаємо стару суму рівною нулю. Це класичний патерн для Map. Він добре поєднується з тим фактом, що доступ за ключем повертає V?.

Тепер — функція друку звіту. І ось тут нам дуже знадобиться обхід Map через деконструкцію:

fun printTotalsReport(totalsByCategory: Map<String, Int>) {
    println("Підсумки за категоріями:")

    for ((category, total) in totalsByCategory) {
        println("- $category: $total")
    }
}

А тепер зберімо це в мікросценарій:

fun main() {
    val expenses = mutableListOf(
        Triple("food", "coffee", 250),
        Triple("food", "sandwich", 350),
        Triple("transport", "metro", 70)
    )

    val totals = buildTotalsByCategory(expenses)
    printTotalsReport(totals)

    // Підсумки за категоріями:
    // - food: 600
    // - transport: 70
}

Нюанс: порядок обходу Map краще не перетворювати на «частину логіки»

Коли ви виводите звіт, іноді хочеться, щоб категорії йшли «у тому порядку, в якому їх додавали». На практиці це може виглядати так, ніби порядок у Map є. Іноді він справді зберігатиметься, бо стандартна реалізація MutableMap у Kotlin — LinkedHashMap, яка під час ітерації зберігає порядок вставки. Але інша реалізація (HashMap) нічого не обіцяє щодо порядку.

Практичне правило: не робіть так, щоб правильність програми залежала від порядку обходу Map. Якщо порядок важливий як частина вимог, це окрема задача, і її треба розв’язувати явно, а не за принципом «ну наче ж друкується гарно».

7. Типові помилки під час обходу Map

Помилка №1: спроба обходити Map за індексами, «як список».
Іноді мозок за звичкою шукає map[0], map[1], map.indices. Але в мапи немає індексів: вона не про позиції, а про відповідності «ключ → значення». Якщо вам потрібен «номер рядка» під час виведення звіту, нумерацію можна зробити окремим лічильником. А сам обхід усе одно має йти через keys/values/entries або деконструкцію.

Помилка №2: обходять keys, а значення дістають через map[key] і дратуються через V?.
Це не «каприз Kotlin», а чесна модель: ключ може бути відсутнім. Якщо вам потрібні пари — обходьте пари. for ((k, v) in map) або for (entry in map.entries) зазвичай повністю знімають проблему, бо в циклі ви вже тримаєте і ключ, і значення — без повторного пошуку.

Помилка №3: пишуть for (k in map.keys) println(map[k]!!).
Це небезпечна звичка. Так, у конкретній ситуації «ключі ж із цієї ж мапи», тому map[k] справді не null. Але ви звикаєте розв’язувати питання null через !!, і потім ця звичка вистрілить в іншому місці — там, де ключі приходять ззовні (наприклад, із вводу користувача). Краще відразу обирати стиль обходу, який не змушує вас доводити очевидне компілятору.

Помилка №4: обходять values, а потім раптом потрібно «зрозуміти, до якого ключа належить значення».
Так буває, коли ви почали з «мені потрібні лише суми», а потім захотіли красиво вивести «категорія: сума». Якщо ключі справді знадобилися, значить, треба обходити пари. values корисні, але лише тоді, коли ви впевнені, що ключі взагалі не беруть участі в логіці.

Помилка №5: будують сенс програми на порядку обходу Map.
Сьогодні все друкується «як треба», завтра ви змінили реалізацію мапи або дані почали приходити в іншому порядку — і вивід «поїхав». Правильна програма не повинна залежати від випадкового порядку. Якщо порядок важливий, його потрібно проєктувати окремо, а не сподіватися на «ну в мене ж зараз працює».

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