JavaRush /Курси /Kotlin SELF /Створення колекцій

Створення колекцій

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

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

Коли кажуть «колекції», зазвичай мають на увазі три великі сімейства: список, множину та мапу.

Щоб було простіше обрати «за змістом», тримайте перед очима таку таблицю:

Тип Що зберігає Що важливо Повтори Як зазвичай використовують
List<T>
елементи порядок та індекс дозволені список витрат, список задач, історія подій
Set<T>
елементи унікальність не зберігаються окремо набір команд, набір категорій, «вже бачили»
Map<K, V>
пари ключ → значення доступ за ключем ключі унікальні налаштування, словник, «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(...) і думає, що це «одне й те саме, просто інше слово». Але масиви — окремий тип зі своїми правилами (наприклад, масиви завжди мутабельні за елементами). Коли ви обираєте контейнер, ви обираєте й стиль роботи з даними. Тож краще від самого початку тримати цю відмінність у голові.

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