JavaRush /Курси /Kotlin SELF /Generics на колекціях + мінімальна структура даних для пр...

Generics на колекціях + мінімальна структура даних для практичного проєкту

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

1. Навіщо потрібні generics і як читати типи колекцій

Якщо ви тільки починаєте, generics легко сприймаються як «ці дивні трикутні дужки, що заважають жити». На практиці ж це ваша страховка від безглуздих помилок. Kotlin справді вміє виводити типи, але виведення типу — це не телепатія. Компілятор спирається на те, що ви йому дали. Якщо ви зберігаєте дані в колекціях, важливо, щоб компілятор точно знав, що саме лежить усередині. Інакше він не зможе захистити вас від випадкової мішанини значень.

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

У Kotlin цей напис виглядає так: List<String>, MutableList<Int>, Map<String, Int>. І це не просто прикраса — це контракт, який компілятор перевіряє за вас. Загальну ідею generics у Kotlin зазвичай описують як «узагальнені параметри типу» (type parameters).

Зараз буде момент, коли мозок може спробувати «вимкнути звук». Тож домовимося так: ми читаємо generics не як «символи», а як українську фразу. Побачили List<T> — вимовили: «список елементів типу T». Побачили Map<K, V> — вимовили: «мапа від ключа типу K до значення типу V». Після кількох десятків повторень це стає автоматикою — приблизно як читання слова println.

У Kotlin-колекцій є інтерфейси й ієрархія, але сьогодні нам важливіше інше — типові контракти: контейнер зберігає елементи заданого виду. Умовно:

  • List<T> зберігає елементи в порядку й дає доступ за індексом.
  • Set<T> зберігає унікальні елементи.
  • Map<K, V> зберігає пари «ключ → значення».

Невелика таблиця «як читати»

Запис типу Як читати українською Приклад значень
List<String>
список рядків
["milk", "bread"]
MutableList<Int>
змінюваний список цілих чисел
[10, 20, 30]
Set<String>
множина унікальних рядків
{"kotlin", "jvm"}
Map<String, Int>
мапа «рядок → ціле число»
{"tea" -> 90}

І так: це все не про те, що «там реально лежить», а про те, що дозволено класти й діставати.

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

Одна з найчастіших ситуацій у новачків: «Я створив порожній список, а Kotlin свариться. Він же розумний». Kotlin розумний, але не ворожка. Якщо ви пишете mutableListOf() без елементів, компілятор не бачить жодного значення й не розуміє, який T ви мали на увазі. І це чесно: список «чого саме» — рядків, чисел, потрійних записів, котиків.

З непорожніми колекціями все простіше: Kotlin бачить елементи й виводить тип за ними. Це стандартна поведінка type inference: типові аргументи можна не вказувати, якщо вони однозначно виводяться з контексту.

Приклад: тип виводиться сам, бо є елементи

fun main() {
    val names = listOf("Ann", "Bob")    // List<String>
    val nums = mutableListOf(1, 2, 3)   // MutableList<Int>

    println(names) // [Ann, Bob]
    println(nums)  // [1, 2, 3]
}

Приклад: порожня колекція — тип потрібно підказати

fun main() {
    val tags = mutableSetOf<String>()
    tags.add("kotlin")

    println(tags) // [kotlin]
}

Чому тут усе добре? Тому що ви вказали <String>, і компілятор заспокоївся.

Два способи підказати тип

Іноді зручніше вказати тип праворуч:

fun main() {
    val ids = mutableListOf<Int>()
    ids.add(10)

    println(ids) // [10]
}

Іноді — ліворуч, особливо якщо тип довгий:

fun main() {
    val ids: MutableList<Int> = mutableListOf()
    ids.add(10)

    println(ids) // [10]
}

Обидва варіанти працюють однаково — обирайте той, що робить код читабельнішим.

2. Найважливіша правда про Map: map[key] повертає V?

З List зазвичай усе інтуїтивно: індекс або існує, або програма впаде з помилкою — і ви швидко зрозумієте, що пішло не так. А от Map поводиться інакше, бо ключ може бути відсутнім — і це цілком нормальна ситуація. Тому під час читання за ключем Kotlin повертає вам не V, а V?. Інакше ви б постійно ловили падіння там, де можна обійтися ввічливою логікою.

Приклад: читання дає Int?

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

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

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

Elvis ?: як запасний план

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

    val milkPrice = prices["milk"] ?: 0
    println(milkPrice) // 0
}

І це не «милиця». Це нормальний, чесний контракт: «може бути значення, а може бути null».

Коли containsKey доречніше, ніж ?:

Іноді ви хочете відрізняти ситуацію «немає ключа» від ситуації «значення за ключем дорівнює 0». Тоді логічніше спочатку перевірити наявність ключа:

fun main() {
    val stock = mapOf("apple" to 0)

    val key = "banana"
    if (stock.containsKey(key)) {
        println("Ключ є, значення = ${stock[key]}") // не виконається
    } else {
        println("Ключа немає") // Ключа немає
    }
}

3. Вкладені generics без болю: як не вбити читабельність

У цьому місці багато хто починає писати такі типи, що хочеться викликати священника й «очистити проєкт від демонів»: MutableMap<String, MutableList<Triple<String, String, Int>>>. Це законно, але читабельність страждає. Нам важливо опанувати два прийоми. По‑перше, не соромитися проміжних val. По‑друге, робити типи помітними в коді завдяки хорошим назвам змінних.

Тобто наше завдання — не просто «написати тип», а подати його так, щоб люди не боялися його читати.

Погана читабельність, але компілюється

fun main() {
    val data: MutableMap<String, MutableList<Triple<String, String, Int>>> = mutableMapOf()
    println(data) // {}
}

Це виглядає як формула з органічної хімії. Може, вона й правильна, але «пити» її не хочеться.

Краще: розкласти на смислові шматки

fun main() {
    val byCategory: MutableMap<String, MutableList<Triple<String, String, Int>>> = mutableMapOf()
    val foodRecords: MutableList<Triple<String, String, Int>> = mutableListOf()

    foodRecords.add(Triple("food", "coffee", 300))
    byCategory["food"] = foodRecords

    println(byCategory) // {food=[(food, coffee, 300)]}
}

Ми ще не вчили «красиві операції з колекціями», тому використовуємо базові кроки: створити, додати, присвоїти за ключем.

4. Мінімальна структура даних для практичного проєкту

Зараз ми зробимо «скелет» даних для проєкту так, щоб він уже був корисним, але не вимагав класів і складної архітектури. Уявіть, що ми пишемо консольний мінітрекер витрат. У кожного запису є категорія, опис і сума. Це ідеально лягає на Triple<String, String, Int>: три поля, простий контракт. Так, Triple — не найчитабельніший формат у великих проєктах. Але для старту це цілком нормальний тимчасовий інструмент — якщо ви дисципліновано дотримуєтеся порядку полів.

Паралельно нам потрібен контейнер, у якому зберігатиметься багато записів. Для цього підійде MutableList<Record>, де Record — це наш Triple<...>.

Крок 1: домовимося про форму запису

fun main() {
    val record = Triple("food", "coffee", 300)

    println(record.first)  // food
    println(record.second) // coffee
    println(record.third)  // 300
}

Так, first/second/third — не мрія з погляду читабельності, але поки згодиться.

Крок 2: сховище записів у списку

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

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

    println(records.size) // 2
}

MutableList у Kotlin — це стандартний змінюваний список, який уміє add/remove та інші операції зміни.

Крок 3: додамо ідентифікатор без класів

Дуже поширена потреба — вміти посилатися на запис за id. Якби в нас були класи, ми б зробили поле id. Але ми поки без ООП, тому використаємо Map<Int, Record>.

Тут важливо памʼятати контракт Map: ключі унікальні, а доступ за ключем може повернути null.

fun main() {
    val byId: MutableMap<Int, Triple<String, String, Int>> = mutableMapOf()

    byId[1] = Triple("food", "coffee", 300)
    byId[2] = Triple("transport", "taxi", 1200)

    val found = byId[2]
    println(found) // (transport, taxi, 1200)
}

Крок 4: мінімальний стан проєкту в памʼяті

Тепер зберемо все разом: список записів (щоб зберігати порядок додавання) і мапу за id (щоб швидко знаходити). Так, це два сховища. Так, це ручна дисципліна синхронізації. Але ми робимо мінімальну версію без архітектурних ускладнень.

fun main() {
    var nextId = 1
    val records: MutableList<Pair<Int, Triple<String, String, Int>>> = mutableListOf()
    val byId: MutableMap<Int, Triple<String, String, Int>> = mutableMapOf()

    val id = nextId++
    val record = Triple("food", "coffee", 300)

    records.add(id to record)
    byId[id] = record

    println(records) // [(1, (food, coffee, 300))]
}

Тут ми використовуємо to (інфіксну функцію) для створення Pair, щоб запис виглядав як «id to record». Це читається майже як український текст: «id відповідає record».

Generics як дизайн API: List<T> vs MutableList<T>

У цей момент зʼявляється тонкий, але дуже практичний принцип: якщо функція не повинна змінювати колекцію, краще приймати read‑only тип (List, Map, Set) замість mutable‑версії. Це робить код безпечнішим: ви одразу показуєте «я тут тільки читаю». У Kotlin це стандартна ідея розділення read‑only і mutable-колекцій.

Покажемо на маленькому прикладі: у нас є функція, яка друкує записи. Їй не потрібно вміти add/remove. Їй потрібно лише пройтися списком і вивести.

fun printRecords(records: List<Pair<Int, Triple<String, String, Int>>>) {
    for ((id, record) in records) {
        val category = record.first
        val title = record.second
        val amount = record.third

        println("#$id [$category] $title = $amount")
    }
}

fun main() {
    val records = mutableListOf<Pair<Int, Triple<String, String, Int>>>()
    records.add(1 to Triple("food", "coffee", 300))

    printRecords(records)
    // #1 [food] coffee = 300
}

Зверніть увагу: ми зберігаємо в MutableList, бо будемо додавати записи, але передаємо у функцію як List, бо функція — «лише читач».

Нормалізація ключів: звичка, яка рятує Map

Мапа (Map) — чудовий інструмент, доки ви не починаєте використовувати як ключі «брудні рядки» з введення користувача. Користувач вводить " Food ", потім "food", потім "FOOD" — і ваш застосунок раптом вирішує, що це три різні категорії. Але застосунок не винен: це ви дали йому три різні рядки.

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

fun normalizeKey(raw: String): String {
    return raw.trim().lowercase()
}

fun main() {
    val categories: MutableSet<String> = mutableSetOf()

    categories.add(normalizeKey(" Food "))
    categories.add(normalizeKey("food"))
    categories.add(normalizeKey("FOOD"))

    println(categories)      // [food]
    println(categories.size) // 1
}

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

5. Типові помилки

Помилка № 1: «Kotlin же розумний, нехай сам здогадається» — створення порожніх колекцій без типу.
Порожня колекція не містить елементів, а отже, компілятору немає з чого вивести T. У результаті ви отримуєте помилку виведення типу. Лікується просто: задавайте тип явно — або через <T> у фабриках (mutableListOf<String>()), або через тип ліворуч (val xs: MutableList<String> = mutableListOf()).

Помилка № 2: плутанина між Map<K, V> і результатом map[key].
Навіть якщо значення в Map — не nullable (V), читання за ключем повертає V?, бо ключ може бути відсутнім. Якщо забути про це й спробувати використати результат як «точно не null», ви швидко вперетеся в помилку компіляції (у найкращому разі) або почнете зловживати !! (у найгіршому). Нормальна стратегія — ?: або явна перевірка наявності ключа.

Помилка № 3: створення монструозних вкладених generic-типів без спроби зробити їх читабельними.
Тип на кшталт MutableMap<String, MutableList<Triple<String, String, Int>>> може бути коректним, але читати його важко — особливо новачку (та й вам самим за тиждень). Рятують проміжні змінні з людськими назвами й маленькі кроки: спочатку список, потім мапа, потім звʼязок між ними.

Помилка № 4: передавання MutableList і MutableMap в усі функції підряд «про всяк випадок».
Це розмиває межі відповідальності: будь‑яка функція раптом отримує право «випадково видалити половину даних». Якщо функція повинна лише читати, приймайте List/Map/Set як read‑only типи. Kotlin спеціально розділяє інтерфейси колекцій на read‑only і mutable, щоб ви могли писати передбачуваніший код.

Помилка № 5: використання сирого введення користувача як ключа без нормалізації.
Якщо ви не робите trim().lowercase(), то "food", " Food " і "FOOD" стають різними ключами. Застосунок виглядає «зламаним», хоча насправді він просто надто буквально сприймає рядок. Зробіть нормалізацію звичкою — і Map почне працювати як інструмент, а не як генератор загадок.

1
Опитування
Колекції `List/Set/Map`, рівень 19, лекція 4
Недоступний
Колекції `List/Set/Map`
Колекції `List/Set/Map`
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ