1. Чому масивів раптово стало замало
Якщо ви зараз думаєте: «Навіщо ще якісь колекції, у мене ж є масиви», — це нормальна реакція. Масиви справді чудово підходять для задач, де кількість елементів наперед відома й майже не змінюється. Але щойно ви починаєте писати програму «для життя», а не «для контрольної», зʼясовується неприємне: реальне життя не любить фіксованих розмірів.
Уявіть, що ви робите консольний мініоблік витрат (ми розвиватимемо цей застосунок далі в курсі). Скільки буде записів? 5? 50? 500? Ви не знаєте. І якщо обрати масив, доведеться або задати величезний розмір «про всяк випадок», або постійно створювати його заново. Обидва варіанти — таке собі: перший витрачає памʼять і виглядає дивно, другий перетворює код на спорт із перенесення коробок.
Масив як коробка з пронумерованими комірками
Масив зручно уявляти як коробку з комірками: у кожної комірки є номер (індекс). Коробку створюють одразу «на N комірок», і збільшити її не можна. Можна лише взяти нову коробку більшого розміру й перекласти туди весь вміст. У Kotlin масиви завжди мутабельні: елемент можна замінити через arr[i] = ..., але розмір лишається тим самим. Це важливо памʼятати, бо «мутабельний масив» — не те саме, що «масив, який зростає».
Ось мінімальний приклад, який показує, що масив «тримає розмір»:
fun main() {
val arr = arrayOf(1, 2, 3)
println(arr.size) // 3
arr[0] = 10
println(arr[0]) // 10
}
Тут ми змінили значення, але розмір — як був 3, так і залишився.
Як ми «розширювали» масив раніше й чому це дратує
Коли в масив потрібно «додати елемент», типовий шлях — створити новий масив більшого розміру й скопіювати туди старий. Навіть якщо ви робите це акуратно, мозок постійно зайнятий не задачею програми, а бухгалтерією памʼяті: «скільки місць», «куди копіювати», «не забути індекс».
Приклад у стилі «додали один елемент» (ідея, а не фінальний стиль застосунку):
fun main() {
var names = arrayOf("Ann", "Bob")
println(names.contentToString()) // [Ann, Bob]
names = names.copyOf(names.size + 1)
names[names.lastIndex] = "Cara"
println(names.contentToString()) // [Ann, Bob, Cara]
}
Працює? Так. Зручно? Не дуже. А головне — щойно таких «додавань» стає багато, код починає виглядати так, ніби ви пишете не програму обліку витрат, а менеджер із переселення масивів.
2. Колекції: контейнери змінного розміру
Колекції — наступний логічний крок. Це контейнери, які вміють зберігати змінну кількість елементів (хоч нуль, хоч тисячу) і водночас дають зручні операції додавання, видалення та пошуку.
З колекціями ви частіше думаєте «що я хочу зробити з даними», а не «як вручну перекласти дані в нову коробку». Це один із тих моментів, коли мова програмування справді спрощує життя, а не просто додає нові слова у ваш словник.
Три базові форми: List, Set, Map
Коли кажуть «колекції», зазвичай мають на увазі три великі сімейства: список, множину та мапу.
Щоб було простіше обрати «за змістом», тримайте перед очима таку таблицю:
| Тип | Що зберігає | Що важливо | Повтори | Як зазвичай використовують |
|---|---|---|---|---|
|
елементи | порядок та індекс | дозволені | список витрат, список задач, історія подій |
|
елементи | унікальність | не зберігаються окремо | набір команд, набір категорій, «вже бачили» |
|
пари ключ → значення | доступ за ключем | ключі унікальні | налаштування, словник, «id → обʼєкт», лічильники |
Зараз ми не заглиблюємося в обхід колекцій, сортування й «красиві операції над колекціями» — це буде далі. Сьогодні завдання простіше: зрозуміти, чому колекції — не те саме, що масиви, чим відрізняються List/Set/Map, і навчитися їх створювати.
Важливий нюанс: масиви — це не колекції
Це тонкість, яка несподівано рятує від плутанини: у Kotlin масиви вважаються окремою сутністю. У документації навіть є пряме зауваження, що масиви — не тип колекції.
На практиці це означає дві речі. По‑перше, масиви мають свій набір можливостей і свої функції, а колекції — свої. По‑друге, читаючи код, важливо не думати: «масив і список — це одне й те саме, просто по‑різному написано». Вони схожі зовні (в обох є елементи), але логіка використання часто різниться.
3. Read-only vs mutable: чому Kotlin розділяє «читати» і «змінювати»
У Kotlin є важлива ідея: «тип говорить про те, що з ним можна робити». Тому колекції поділяються на read-only інтерфейси та mutable-інтерфейси. І це не просто «любов до ускладнення життя студента», а практичний захист від випадкових змін даних.
У стандартній бібліотеці для кожного базового виду колекції є пара інтерфейсів: read-only (лише читання) та mutable (читання + зміна). Саме тому існують пари List/MutableList, Set/MutableSet, Map/MutableMap. Виглядає як «два майже однакові слова», але значення в них різне.
Read-only колекція — це «через це посилання не можна змінювати»
Read-only тип (List, Set, Map) означає: через цю змінну у вас немає операцій зміни (add, remove, присвоєння за індексом тощо). Ви можете читати розмір, брати елементи, перевіряти наявність, але не «перебудовувати контейнер».
Це дуже корисно, коли ви хочете явно сказати: «ось тут ми тільки читаємо». Наприклад, функція отримує список витрат і має лише порахувати суму. Без ризику «випадково видалити половину витрат під час підрахунку», якщо хтось переплутав методи.
Приклад «тільки читання» виглядає так:
fun main() {
val names: List<String> = listOf("Ann", "Bob", "Ann")
println(names.size) // 3
println(names) // [Ann, Bob, Ann]
}
Тут names — read-only список. Він може містити повтори, і порядок елементів зберігається.
Mutable колекція — це «можна змінювати вміст»
Mutable тип (MutableList, MutableSet, MutableMap) означає: ви можете додавати, видаляти та оновлювати елементи.
Найбазовіший приклад:
fun main() {
val numbers = mutableListOf(1, 2, 3)
numbers.add(4)
println(numbers) // [1, 2, 3, 4]
}
Ось тут і відбувається магія, заради якої ми й прийшли: список виріс без створення нової «коробки».
val і «мутабельність усередині»: часте джерело плутанини
Дуже важливе правило, яке «ламає мозок» рівно один раз (а потім стає очевидним): val забороняє перепризначати змінну, але не забороняє змінювати обʼєкт, на який ця змінна посилається.
Подивіться на приклад і відчуйте різницю:
fun main() {
val xs = mutableListOf("one", "two", "three")
xs.add("four") // можна
println(xs) // [one, two, three, four]
// xs = mutableListOf("X") // не можна: val не можна перепризначити
}
Це дуже популярний стиль: «колекція mutable, але змінна — val». Так ви ніби кажете: «вміст можна змінювати, але сам контейнер (як обʼєкт) випадково не підміниться іншим».
Read-only — це не «абсолютно незмінне в усьому всесвіті»
Ще один тонкий момент: read-only тип не обіцяє, що дані взагалі ніколи не змінюються. Він гарантує лише те, що через це конкретне посилання ви не можете викликати методи зміни.
Якщо десь є інше посилання на ту саму mutable-колекцію, зміни можуть відбуватися «з іншого боку». На цьому етапі курсу нам не потрібно заходити в складні теми про «спільно використовувані посилання». Але корисно запамʼятати інтуїцію: read-only — це обмеження API, а не чарівний купол незмінюваності.
4. Створення колекцій: listOf, mutableListOf, setOf, mapOf
Досі ми створювали масиви через arrayOf(...) або IntArray(...). З колекціями все ще простіше: у Kotlin є фабричні функції, які створюють потрібний тип за значеннями. Це читається майже як звичайний текст: «створи список із цих елементів».
І так, ці функції використовують постійно: ви будете бачити їх у прикладах, у документації та в реальному коді. Тому важливо звикнути до них зараз — ще до того, як ми почнемо будувати логіку на колекціях.
listOf(...) і mutableListOf(...)
listOf(...) створює read-only List. Це зручно, коли у вас є «набір значень», який ви не плануєте змінювати.
mutableListOf(...) створює MutableList, який можна розширювати й скорочувати.
Приклад поруч, щоб порівняти:
fun main() {
val readOnly = listOf("A", "B", "C")
println(readOnly) // [A, B, C]
val mutable = mutableListOf("A", "B", "C")
mutable.add("D")
println(mutable) // [A, B, C, D]
}
Змістова порада на майбутнє (без фанатизму): якщо не впевнені, що колекцію потрібно змінювати, починайте з read-only. Mutable беріть тоді, коли зміни справді потрібні.
setOf(...) і mutableSetOf(...)
setOf(...) створює множину унікальних елементів. Якщо ви випадково повторите елемент, множина не стане «довшою», бо її сенс — унікальність.
Базовий приклад:
fun main() {
val tags = setOf("kotlin", "kotlin", "jvm")
println(tags) // наприклад: [kotlin, jvm]
println(tags.size) // 2
}
Зауважте: порядок виведення ми не використовуємо як частину логіки. Ми сприймаємо Set як «мішечок унікальних значень».
А ось mutable-варіант:
fun main() {
val commands = mutableSetOf("add", "list")
commands.add("remove")
commands.add("add") // повтор
println(commands) // [add, list, remove] (повтор не додався)
}
mapOf(...), mutableMapOf(...) та інфіксна функція to
Мапа (Map) зберігає пари «ключ → значення». Ключі унікальні.
Створювати мапу зручно через to:
fun main() {
val prices = mapOf(
"tea" to 90,
"coffee" to 150
)
println(prices) // {tea=90, coffee=150}
}
Із mutableMapOf можна змінювати й додавати значення:
fun main() {
val stock = mutableMapOf("apple" to 3)
stock["apple"] = 4
stock["banana"] = 1
println(stock) // {apple=4, banana=1}
}
Невелика чесність наперед: читання map[key] може повернути null, якщо ключа немає. Це нормальна модель безпеки. Детально й спокійно ми розберемо це в окремій лекції про Map, а зараз просто тримаймо це в голові як факт, щоб не дивуватися.
5. Порожні колекції та виведення типу: чому компілятор просить підказку
Коли ви створюєте непорожню колекцію, Kotlin зазвичай виводить тип елементів автоматично. Але якщо колекція порожня, компілятору «нема за що зачепитися»: він не бачить жодного елемента й не може зрозуміти, який тип там має бути. Тому порожні колекції часто створюють із явним зазначенням типу.
Це важливий практичний момент: порожні колекції трапляються постійно, особливо в застосунках, де дані накопичуються в міру роботи програми.
Непорожня колекція: тип виводиться сам
Приклад, де Kotlin сам розуміє тип:
fun main() {
val a = listOf(1, 2, 3) // List<Int>
val b = setOf("kotlin", "jvm") // Set<String>
val c = mapOf("a" to 1) // Map<String, Int>
println(a) // [1, 2, 3]
println(b) // [kotlin, jvm]
println(c) // {a=1}
}
Тут усе просто: елементи підказали тип.
Порожня колекція: тип потрібно задати
Тепер створімо порожні колекції правильно:
fun main() {
val items = mutableListOf<String>()
val categories = mutableSetOf<String>()
val counts = mutableMapOf<String, Int>()
items.add("milk")
categories.add("food")
counts["milk"] = 1
println(items) // [milk]
println(categories) // [food]
println(counts) // {milk=1}
}
Зверніть увагу: у кожному рядку <String> або <String, Int> — це не «зайвий шум», а підказка компілятору. Без неї він не зобовʼязаний вгадувати.
6. Мініначерк застосунку: навіщо нам колекції в реальності
Відсьогодні в курсі ми починаємо збирати практичний консольний застосунок (без класів і ООП на цьому етапі). Щоб не було відчуття «колекції заради колекцій», привʼяжімо все до простої життєвої задачі: зберігати введені користувачем записи про витрати. Сьогодні зробимо лише самий початок: сховище в памʼяті та кілька дій. Команди, нормалізацію введення й повноцінну логіку розберемо пізніше. А зараз важливо побачити головне: колекції розвʼязують проблему розміру.
Уявімо запис витрати, поки що, просто рядком (на кшталт "food coffee 300"). Так, це ще не ідеальна модель даних, але на поточному рівні знань — чесний і робочий тимчасовий формат.
Версія на масиві: незручно й шумно
Ось як виглядав би «збір витрат» на масиві (спрощено):
fun main() {
var expenses = emptyArray<String>()
println("Введіть витрату (або порожній рядок для виходу):")
while (true) {
val line = readln().trim()
if (line.isEmpty()) break
expenses = expenses.copyOf(expenses.size + 1)
expenses[expenses.lastIndex] = line
}
println("Збережено записів: ${expenses.size}") // наприклад: 2
}
Працює, але відчуття таке, ніби ми займаємося не витратами, а перепакуванням даних.
Версія на MutableList: коротше, чистіше, ближче до змісту
А тепер те саме на колекціях:
fun main() {
val expenses = mutableListOf<String>()
println("Введіть витрату (або порожній рядок для виходу):")
while (true) {
val line = readln().trim()
if (line.isEmpty()) break
expenses.add(line)
}
println("Збережено записів: ${expenses.size}") // наприклад: 2
}
Різниця не лише в кількості рядків. Вона ще й у тому, про що ви думаєте, поки пишете код. Тут у голові — «додати запис», а не «створити новий масив і скопіювати дані». Саме за це колекції й люблять у прикладному коді.
Як швидко обрати контейнер: проста схема для початківців
На початку знайомства з колекціями легко зависнути: «а що обрати? список? множину? мапу?». Це нормально. Зафіксуймо дуже просту модель вибору — без занурення в деталі продуктивності та внутрішньої реалізації.
Якщо важливий порядок елементів і допускаються повтори, майже завжди це List. Якщо потрібна унікальність значень — беріть Set. Якщо треба зберігати відповідність «ключ → значення» й діставати за ключем — беріть Map.
Можна навіть уявити це як мініблок-схему:
flowchart TD
A["Потрібно зберігати кілька значень"] --> B{"Потрібен доступ за ключем?"}
B -->|Так| M["Map⟨K, V⟩"]
B -->|Ні| C{"Потрібна унікальність?"}
C -->|Так| S["Set⟨T⟩"]
C -->|Ні| L["List⟨T⟩"]
Ця схема — не «вища математика», але вона допомагає швидко ухвалити рішення в більшості побутових випадків.
7. Типові помилки під час першого знайомства з колекціями
Помилка № 1: плутати read-only та immutable («раз не можна змінювати через List, значить ніхто не змінює взагалі»).
Read-only типи обмежують операції через конкретне посилання, але не гарантують, що десь в іншому місці немає mutable-посилання на ті самі дані. На старті курсу достатньо памʼятати просте правило: read-only — це про API й доступні методи, а не про чарівну незмінюваність світу.
Помилка № 2: тримати mutable-колекцію в var просто «про всяк випадок».
Часто початківці пишуть var list = mutableListOf(...), бо «ну раз там зміни, значить var». Насправді зміни відбуваються методами колекції (add, remove), а не перепризначенням змінної. Тому зручний і безпечний стиль — val list = mutableListOf(...): так ви випадково не підміните колекцію іншою.
Помилка № 3: створювати порожню колекцію без типу й дивуватися помилці компіляції.
Коли ви пишете mutableListOf() без елементів, компілятор не зобовʼязаний вгадувати, MutableList чого саме ви хочете. Рішення просте: вказувати тип явно (mutableListOf<String>(), mutableMapOf<String, Int>()) або задавати очікуваний тип змінної.
Помилка № 4: сприймати Set як «список без індексів, але загалом те саме».
У множини інший зміст — унікальність. Якщо ви починаєте чекати від Set «перший елемент», «другий елемент», «порядок додавання», ви будуєте крихку логіку. На рівні сьогоднішньої теми ми ставимося до Set як до «набору унікальних значень», і цього достатньо.
Помилка № 5: намагатися «оновлювати» listOf/setOf/mapOf, ніби вони mutable.
Фабрики listOf, setOf, mapOf створюють read-only колекції. Якщо ви наперед знаєте, що змінюватимете вміст, обирайте mutableListOf, mutableSetOf, mutableMapOf. Це не примха, а спосіб зробити наміри коду зрозумілими без коментарів.
Помилка № 6: забувати, що масиви та колекції — різні сутності.
Іноді початківець бачить arrayOf(...), потім бачить listOf(...) і думає, що це «одне й те саме, просто інше слово». Але масиви — окремий тип зі своїми правилами (наприклад, масиви завжди мутабельні за елементами). Коли ви обираєте контейнер, ви обираєте й стиль роботи з даними. Тож краще від самого початку тримати цю відмінність у голові.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ