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> зберігає пари «ключ → значення».
Невелика таблиця «як читати»
| Запис типу | Як читати українською | Приклад значень |
|---|---|---|
|
список рядків | |
|
змінюваний список цілих чисел | |
|
множина унікальних рядків | |
|
мапа «рядок → ціле число» | |
І так: це все не про те, що «там реально лежить», а про те, що дозволено класти й діставати.
Виведення типу: чому з непорожньою колекцією легко, а з порожньою — ні
Одна з найчастіших ситуацій у новачків: «Я створив порожній список, а 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 почне працювати як інструмент, а не як генератор загадок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ