JavaRush /Курси /Kotlin SELF /Map і MutableMap: читання V?, оновлення за ключем, патерн...

Map і MutableMap: читання V?, оновлення за ключем, патерн «лічильник»

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

1. Навіщо потрібен Map і як читати Map<K, V>

Коли ви лише звикаєте до колекцій, дуже легко мислити так: «Мені потрібен список, отже — List». І це правда… доки ви не стикаєтеся із завданням, де ключ до даних — це не позиція (0, 1, 2…), а якась осмислена мітка: назва товару, команда, логін, категорія, ID.

Map<K, V> (мапа, словник) зберігає пари «ключ → значення». Ключі унікальні: два однакові ключі в одній мапі існувати не можуть. Натомість значення можуть повторюватися скільки завгодно: наприклад, товари можуть мати однакову ціну, але ключі (назви товарів) усе одно різні.

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

Як читати тип Map<K, V> і не лякатися кутових дужок

Коли ви бачите Map<String, Int>, мозок спочатку вдає, що зайнятий: «давай потім, я не готовий». Це нормально. Тут важливо навчитися читати тип уголос — як речення. Map<K, V> читається як «мапа від ключів типу K до значень типу V». Тобто Map<String, Int> — це «ключі — рядки, значення — цілі числа».

У реальному житті це може бути, наприклад, «назва товару → ціна», «категорія → сума витрат», «слово → кількість разів, коли воно трапилося». Жодної магії: кутові дужки просто фіксують, що саме ми кладемо й дістаємо.

Подивімося на «кишеньковий прайс-лист»:


fun main() {
    val prices: Map<String, Int> = mapOf(
        "tea" to 90,
        "coffee" to 150
    )

    println(prices) // {tea=90, coffee=150}
}

Зверніть увагу на to. Це зручний спосіб створити пару «ключ → значення», щоб mapOf(...) читався як «перелік відповідностей».

2. Читання з Map: чому отримуємо V? і як із цим жити

Якщо ви звикли до List, то очікуєте логіку «дай елемент за індексом — і отримаєш елемент». Але в Map ключ може бути відсутнім. І Kotlin робить дуже правильну річ: замість того щоб «вибухнути» помилкою, читання за ключем повертає null, якщо ключ не знайдено.

Саме тому вираз map[key] має тип V?, навіть якщо V сам по собі не допускає null. Це одна з найважливіших деталей, і вона постійно траплятиметься вам, доки ви пишете будь-які програми з Map.

fun main() {
    val prices = mapOf("tea" to 90, "coffee" to 150)

    val a: Int? = prices["tea"]
    val b: Int? = prices["milk"]

    println(a) // 90
    println(b) // null
}

Тут немає «помилки». milk просто відсутній — і це нормальна ситуація, а не катастрофа.

Elvis ?: під час читання: «якщо ключа немає — беріть значення за замовчуванням»

Після того як ви прийняли реальність V?, виникає наступне питання: «Гаразд, а що робити, якщо null мені не підходить?» У простих сценаріях найкраще працює оператор Елвіса ?:: «якщо зліва null, то візьміть справа».

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

fun main() {
    val prices = mapOf("tea" to 90, "coffee" to 150)

    val milkPrice = prices["milk"] ?: 0
    val teaPrice = prices["tea"] ?: 0

    println(milkPrice) // 0
    println(teaPrice)  // 90
}

Тут ми явно обираємо політику: «немає ключа → 0». Важливо, що Kotlin змушує вас ухвалити рішення, а не вдавати, ніби ключ «точно є».

Перевірка наявності ключа: key in map як читабельний стиль

Іноді значення за замовчуванням не підходить. Наприклад, якщо команда не існує, ви хочете вивести повідомлення про помилку, а не «тихо» продовжити роботу. Тоді спочатку перевіряємо, чи є ключ у мапі.

Kotlin дозволяє писати це дуже читабельно: if (key in map). За змістом це означає: «якщо ключ входить до набору ключів мапи».

fun main() {
    val prices = mapOf("tea" to 90, "coffee" to 150)
    val item = "tea"

    if (item in prices) {
        println("Ціна: ${prices[item]}") // Ціна: 90
    } else {
        println("Немає такого товару")           // (не виконається)
    }
}

Тут prices[item] усе ще повертає Int?, але в такому коді ви зазвичай уже впевнені, що ключ є. Трохи пізніше в курсі ви навчитеся робити це ще акуратніше, але на поточному етапі цього достатньо: перевірка + зрозуміла гілка поведінки.

3. MutableMap: запис, оновлення та видалення

До цього ми використовували mapOf(...), тобто Map лише для читання. Її завдання — «зберігати й давати читати». Але якщо вам потрібно змінювати дані під час роботи застосунку, беріть MutableMap.

У Kotlin цей поділ такий самий, як у List: тип лише для читання (Map) і змінюваний тип (MutableMap). Водночас MutableMap підтримує операції запису: можна додавати нові пари й оновлювати наявні значення.

Одна форма для «додати» й «оновити»

Найприємніше — запис виглядає однаково і для «додати», і для «оновити»:

fun main() {
    val stock = mutableMapOf<String, Int>()

    stock["apple"] = 3
    stock["apple"] = 4
    stock["banana"] = 1

    println(stock) // {apple=4, banana=1}
}

Тобто оператор [] для MutableMap працює в обидва боки: читання map[key] і запис map[key] = value. Це одна з причин, чому мапи в Kotlin відчуваються як «чесний словник», а не як щось громіздке.

Невелика практична деталь: стандартна реалізація MutableMap за замовчуванням — LinkedHashMap, і під час обходу вона зберігає порядок додавання. Сьогодні ми не будемо обходити мапу (це тема наступного дня), але корисно розуміти, чому під час println(map) ви часто бачите «пари в очікуваному порядку».

Видалення за ключем: remove(key) і «що саме повернулося»

Видалення в MutableMap зазвичай робиться так: remove(key). І тут Kotlin знову поводиться «по-дорослому»: метод повертає значення, яке було видалено, або null, якщо ключа не було.

Це зручно, бо одним виразом ви можете дізнатися, чи було що видаляти.

fun main() {
    val stock = mutableMapOf("apple" to 4, "banana" to 1)

    val removed: Int? = stock.remove("banana")
    val removedAgain: Int? = stock.remove("banana")

    println(removed)      // 1
    println(removedAgain) // null
    println(stock)        // {apple=4}
}

Знову та сама філософія: «відсутність ключа» — нормальний стан світу, а не виняток.

Міні-таблиця: що повертають базові операції Map

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

Операція Приклад Що повертається Чому
Читання за ключем
prices["tea"]
V?
ключ може бути відсутнім
Перевірка ключа
"tea" in prices
Boolean
лише факт наявності
Запис/оновлення
stock["tea"] = 100
Unit
це дія, а не «значення»
Видалення
stock.remove("tea")
V?
можна дізнатися, що видалили

Цю таблицю не «треба вчити напамʼять», а «треба звикнути». Через пару вправ мозок почне автоматично очікувати nullable під час читання з мапи.

4. Патерн «лічильник»: збільшуємо значення за ключем

Зараз буде один із найпрактичніших прийомів. Він трапляється всюди: статистика, підрахунки, частоти, «скільки разів користувач зробив X», «яка сума витрат у категорії Y». І робиться це буквально в один рядок.

Проблема звучить так: «Я хочу збільшити лічильник за ключем. Але якщо ключа немає, лічильник має вважатися рівним 0».

Рішення: (map[key] ?: 0) + 1, після чого записуємо результат назад.

fun main() {
    val counts = mutableMapOf<String, Int>()
    val word = "kotlin"

    counts[word] = (counts[word] ?: 0) + 1
    counts[word] = (counts[word] ?: 0) + 1

    println(counts) // {kotlin=2}
}

Виглядає майже надто просто — але це саме та простота, заради якої люди люблять Kotlin: усе чесно, і null оброблено явно.

Приклад «лічильник слів» без функціональних ланцюжків

Дуже хочеться вже писати красиво через map/filter/groupBy, але ми домовилися: сьогодні без «операцій над колекціями», лише базові інструменти. Тому зробимо максимально зрозумілий варіант: читаємо рядок, розбиваємо на слова, нормалізуємо регістр і рахуємо частоти.

Ми рахуватимемо слова, розділені пробілами. Це не ідеальний парсер тексту (розділові знаки залишаться в словах), але для відпрацювання Map цього цілком достатньо.

fun main() {
    val line = readln().trim().lowercase()
    val parts = line.split(" ")

    val counts = mutableMapOf<String, Int>()

    for (p in parts) {
        if (p != "") counts[p] = (counts[p] ?: 0) + 1
    }

    println(counts)
}

Якщо ввести:

Kotlin kotlin  is  fun

то виведення буде приблизно таким (порядок може збігатися з порядком додавання):

// {kotlin=2, is=1, fun=1}

Тут ми одночасно побачили три речі: нормалізацію рядка (trim().lowercase()), базовий цикл for і той самий патерн лічильника.

5. Приклад: «категорія → сума» і нормалізація ключів

Тепер акуратно привʼяжемо Map до нашого практичного контексту: ми поступово рухаємося до застосунку, який зберігає записи (наприклад, витрати) у памʼяті. Поки без класів, тому запис подамо як Triple(category, title, amount). Сьогодні наша мета — не «ідеальна архітектура», а практика мапи: побудуємо звіт «за категоріями».

Зробимо функцію, яка за списком записів будує MutableMap<String, Int>, де ключ — категорія, а значення — сума витрат за категорією.

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

    for (r in records) {
        val category = r.first
        val amount = r.third
        totals[category] = (totals[category] ?: 0) + amount
    }

    return totals
}

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

Перевірмо на маленькому прикладі:

fun main() {
    val records = mutableListOf<Triple<String, String, Int>>()

    records.add(Triple("food", "coffee", 300))
    records.add(Triple("transport", "taxi", 1200))
    records.add(Triple("food", "sandwich", 250))

    val totals = buildTotalsByCategory(records)
    println(totals) // {food=550, transport=1200}
}

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

Нюанс про ключі: нормалізація рядка як частина контракту

Мапи чесно порівнюють ключі за точною рівністю. Для рядка це означає, що "Food", "food" і " food " — три різні ключі. І це не «проблема Kotlin», а ваша відповідальність як автора застосунку.

Тому, якщо ключ приходить від користувача (наприклад, він вводить категорію), хороше правило — нормалізувати його: trim() і часто lowercase(). Тоді ваша Map відображатиме зміст даних, а не випадкові пробіли й настрій Caps Lock.

Міні-приклад:

fun main() {
    val totals = mutableMapOf<String, Int>()

    val raw = "  FOOD "
    val category = raw.trim().lowercase()

    totals[category] = (totals[category] ?: 0) + 100

    println(totals) // {food=100}
}

Якщо ви не зробите таку нормалізацію, то в звіті раптом зʼявляться категорії Food, FOOD, food — і вам здаватиметься, що застосунок «зламався», хоча він просто надто буквально сприймає введення.

6. Типові помилки під час роботи з Map/MutableMap

Помилка №1: очікувати, що map[key] поверне V, а не V?.
Новачки часто пишуть val x: Int = prices["tea"] і дивуються помилці компіляції. Компілятор тут ваш союзник: він попереджає, що ключ може бути відсутнім. Виправлення зазвичай просте: або Elvis ?:, або перевірка if (key in map), або інший контракт функції (наприклад, повернути Int? назовні).

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

Помилка №3: намагатися оновлювати Map, створену через mapOf.
mapOf(...) повертає Map лише для читання. Вона для читання, а не для змін. Якщо потрібно додавати, видаляти або оновлювати, використовуйте mutableMapOf(...). Це не прискіпування: поділ на read-only і mutable — важлива частина дизайну колекцій у Kotlin.

Помилка №4: забути про нормалізацію ключа й отримати «розмноження сутностей».
Коли ключ — рядок від користувача, пробіли й регістр перетворюються на різні ключі. У звіті зʼявляються дублікати «за змістом», і ви починаєте підозрювати щось дивне. Зазвичай достатньо домовитися: ключі зберігаємо в trim().lowercase(), а «гарне відображення» зробимо пізніше.

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

Помилка №6: плутати «ключ» та «індекс» і намагатися мислити мапою як списком.
У Map немає «нульового елемента» і «останнього індексу» в тому сенсі, як у списку. Є лише ключі. Якщо ви ловите себе на бажанні «пробігтися по мапі за індексами», значить ви або розвʼязуєте завдання обходу (ми це розберемо пізніше), або вам насправді потрібен List.

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