1. Навіщо потрібне групування
Коли даних стає більше, простий список починає поводитися як шафа без полиць: речі начебто є, але знайти «усі футболки» серед «усього підряд» складно. Групування — це спосіб узяти плоский список і розкласти елементи по «кошиках» (групах) за певною ознакою: за категорією, за першою літерою, за довжиною рядка, за роком тощо.
У Kotlin групування найчастіше роблять функцією groupBy. Ідея проста: для кожного елемента обчислюється ключ групування, а потім елементи збираються в мапу: ключ → список елементів, для яких вийшов саме цей ключ.
Уявіть, що у вас є витрати:
- "food" 120 "lunch"
- "taxi" 250 "airport"
- "food" 80 "coffee"
Ви хочете отримати структуру такого вигляду:
- food → [...усі витрати food...]
- taxi → [...усі витрати taxi...]
Саме це й повертає groupBy.
2. groupBy: ключі та результат Map<K, List<T>>
Якщо пояснювати «по-дорослому», groupBy робить дві речі: обчислює ключ для кожного елемента й збирає елементи в групи за цим ключем. Важливо одразу зафіксувати головну думку: результат — не Map<K, T>, а саме Map<K, List<T>>, бо елементів з одним і тим самим ключем може бути багато.
Форма результату така:
- вхід: List<T>
- вихід: Map<K, List<T>>
Де K — тип ключа, який повертає ваша лямбда (наприклад, Int, Char, String), а T — тип елементів початкового списку.
Невеликий приклад «у вакуумі» (тобто максимально чистий, без нашого проєкту):
fun main() {
val words = listOf("one", "two", "three", "four")
val byLength: Map<Int, List<String>> = words.groupBy { it.length }
println(byLength) // {3=[one, two], 5=[three], 4=[four]}
}
Тут ключ — довжина слова (Int), а значення — список слів цієї довжини.
Так, groupBy — стандартний і дуже «канонічний» прийом у стилі Kotlin. Його часто показують як базовий інструмент роботи з колекціями.
Чому в значенні саме List
Є момент, який у новачків викликає легке «зачекайте…»: чому Kotlin не повертає щось на кшталт Map<K, Set<T>>? Чому саме List?
Відповідь практична: groupBy зберігає усі елементи і лишає їх у порядку початкового списку. Тобто всередині кожної групи порядок буде таким самим, у якому елементи траплялися в оригіналі. Це зручно: часто ви вже відсортували вхідні дані, або вам важливий порядок додавання, або просто потрібен передбачуваний вивід.
До того ж List — най«мʼякший» контейнер. Він не вимагає унікальності (у групі можуть бути однакові елементи), не змушує думати про хешування й загалом добре відповідає ідеї «зібрали все як є, а потім розберемося».
Якщо вам потрібен інший контейнер (наприклад, Set), це вже наступний крок обробки: спочатку згрупували, потім перетворили. А сьогодні ми якраз тренуємося отримувати й читати базову форму Map<K, List<T>>.
Як Kotlin вирішує, що вважати «однаковим»
Ключ групування — це результат вашої лямбди keySelector, тобто виразу у фігурних дужках. Kotlin обчислює його для кожного елемента й використовує як «ярлик кошика», до якого цей елемент потрапить.
Приклад: групуємо слова за першою літерою.
fun main() {
val words = listOf("one", "two", "three", "four")
val byFirstChar: Map<Char, List<String>> = words.groupBy { it[0] }
for ((ch, group) in byFirstChar) {
println("$ch -> $group")
}
// o -> [one]
// t -> [two, three]
// f -> [four]
}
Зверніть увагу: тип ключа тут — Char. Kotlin виводить його з виразу it[0].
І тут зʼявляється перша «доросла» обережність: якщо рядок може бути порожнім, it[0] упаде з помилкою. Тому під час групування за символами майже завжди спочатку відфільтровують порожні рядки.
3. Як читати результат groupBy
Після groupBy ви отримуєте Map. А отже, читати результат можна рівно тими самими способами, що й будь-яку мапу: обходом entries (або через деструктуризацію в for) чи доступом за ключем.
Нагадаю важливу деталь: Map зберігає пари «ключ — значення»; ключі в ній унікальні; а читання за ключем повертає nullable-значення, бо ключ може бути відсутнім. Це напряму впливає на те, як безпечно читати групи.
Найзручніший спосіб — просто обійти всі групи:
fun main() {
val words = listOf("one", "two", "three", "four")
val groups = words.groupBy { it.length }
for ((len, group) in groups) {
println("len=$len, count=${group.size}, items=$group")
// len=3, count=2, items=[one, two]
// len=5, count=1, items=[three]
// len=4, count=1, items=[four]
}
}
Тут group — це звичайний List<String>. У нього є size, його можна перебирати, сортувати (якщо дуже треба), будувати з нього рядки тощо.
Якщо ж ви хочете взяти «конкретну» групу за ключем, потрібно памʼятати про nullable:
fun main() {
val words = listOf("one", "two", "three", "four")
val groups = words.groupBy { it.length }
val len4: List<String> = groups[4] ?: emptyList()
println(len4) // [four]
}
Це один із найспокійніших патернів: «якщо групи немає — вважаємо, що вона порожня». Він особливо зручний у звітах.
4. BudgetBuddy: групуємо витрати за категоріями
Тепер зробімо те, заради чого все й затівалося: звіт «витрати за категоріями». Тут важливо не «стрибнути в космос», а акуратно поєднати знання з попередніх тем із новою функцією.
У нас є список витрат:
- category: String
- amount: Int (поки цілі числа, щоб не сперечатися з копійками та Double)
- note: String
Опишімо тип запису як Triple і підготуємо тестові дані:
fun main() {
val expenses: List<Triple<String, Int, String>> = listOf(
Triple("food", 120, "lunch"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee"),
Triple("books", 500, "kotlin book")
)
val byCategory = expenses.groupBy { it.first }
println(byCategory.keys) // [food, taxi, books]
}
Ключ тут — it.first, тобто категорія. А значення — список трійок, які належать до цієї категорії.
Корисно помітити, як швидко «на відчуття» стає легше працювати: замість одного довгого списку у нас зʼявляється структура, у якій дані вже «розкладені по поличках».
Друкуємо звіт за групами
Хороший звіт — це не просто println(map) (хоча інколи й це вже щастя). Зазвичай хочеться виводити блоками: спочатку категорія, потім елементи всередині неї.
Зробімо функцію друку «секцій». Зверніть увагу: ми не будуємо складної архітектури й не забігаємо наперед — просто акуратно використовуємо groupBy і цикли.
fun printByCategoryReport(expenses: List<Triple<String, Int, String>>) {
val groups = expenses.groupBy { it.first }
for ((category, items) in groups) {
println("== $category ==")
for ((_, amount, note) in items) {
println("- $amount : $note")
}
println()
}
}
І приклад виклику:
fun main() {
val expenses = listOf(
Triple("food", 120, "lunch"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee")
)
printByCategoryReport(expenses)
// == food ==
// - 120 : lunch
// - 80 : coffee
//
// == taxi ==
// - 250 : airport
}
Зверніть увагу на маленький UX-момент: ми додали порожній рядок між категоріями. Це дрібниця, але вона перетворює «лог» на «звіт».
5. Корисні нюанси groupBy
Нормалізація ключа
Коли ви вперше впроваджуєте групування в реальний застосунок, раптом зʼясовується, що користувачі (та й ви самі) вводять категорії як завгодно: "Food", " food ", "FOOD". Якщо групувати «як є», ви отримаєте три групи замість однієї. І це не баг groupBy, а чесний результат: ключі ж різні.
Тому ключ групування часто роблять нормалізованим: trim() + lowercase().
fun normalizeCategory(raw: String): String {
return raw.trim().lowercase()
}
fun main() {
val expenses = listOf(
Triple(" Food ", 120, "lunch"),
Triple("food", 80, "coffee"),
Triple("FOOD", 200, "dinner")
)
val groups = expenses.groupBy { normalizeCategory(it.first) }
println(groups.keys) // [food]
}
Зверніть увагу на ідею: нормалізація — частина keySelector. Тобто ми не «підправили потім», а одразу домовилися, що ключі рахуємо за змістом.
groupBy(keySelector, valueTransform)
Іноді вам не потрібні «повні записи» в групі. Наприклад, для звіту за категоріями ви хочете зберігати всередині групи лише суми (amount), щоб потім рахувати агрегати. Або лише нотатки. Або, скажімо, тільки «людський текст», готовий до друку.
Для таких випадків у groupBy є форма з двома лямбдами: одна визначає ключ, а друга перетворює елемент перед тим, як покласти його в групу.
Ідея проста: «згрупуй за ключем, але в кожній групі зберігай не T, а V».
Приклад: згрупуймо витрати за категорією, але покладемо всередину лише amount.
fun main() {
val expenses = listOf(
Triple("food", 120, "lunch"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee")
)
val amountsByCategory: Map<String, List<Int>> = expenses.groupBy(
keySelector = { it.first },
valueTransform = { it.second }
)
println(amountsByCategory) // {food=[120, 80], taxi=[250]}
}
Це все ще Map<K, List<...>>, просто тепер усередині списку лежить інший тип (Int), а не початкова трійка.
Так само можна зберігати заздалегідь підготовлені рядки для друку:
fun main() {
val expenses = listOf(
Triple("food", 120, "lunch"),
Triple("food", 80, "coffee")
)
val linesByCategory = expenses.groupBy(
keySelector = { it.first },
valueTransform = { (_, amount, note) -> "$amount : $note" }
)
println(linesByCategory["food"])
// [120 : lunch, 80 : coffee]
}
Цей прийом особливо хороший, коли ви хочете спростити вивід і не тягнути далі «важкі» структури.
Мінісхема: що робить groupBy
Щоб закріпити «форму результату», корисно хоча б раз побачити це як процес. Це не магія і не «особлива структура даних», а цілком зрозуміле перекладання елементів.
flowchart TD
A["List⟨T⟩ (плоский список)"] -->|для кожного елемента рахуємо key| B["ключ K"]
B --> C["Map⟨K, List⟨T⟩⟩"]
A -->|елемент додається до списку своєї групи| C
Тобто groupBy не «втрачає елементи» і не «рахує статистику». Він саме розкладає елементи за групами, зберігаючи їх як списки.
6. Типові помилки під час роботи з groupBy
Помилка № 1: очікувати Map<K, V>, а отримати Map<K, List<V>>.
Це найчастіша плутанина. Мозок новачка інколи хоче «категорія → одна витрата», але групування за означенням робить «категорія → багато витрат». Якщо вам справді потрібен один елемент на ключ, це вже інша задача (і інший інструмент). А для groupBy корисно подумки проговорювати: «група — це список».
Помилка № 2: групувати за it[0], не переконавшись, що рядок не порожній.
groupBy тут не винен: ви самі звертаєтеся до нульового символу. У реальному введенні порожні рядки зʼявляються постійно (подвійні пробіли, порожні команди, випадковий Enter). Рятує дисципліна: перед групуванням фільтрувати порожні значення або обирати ключ, який не потребує ризикованого доступу за індексом.
Помилка № 3: отримувати «подвійні» категорії через пробіли та регістр.
Категорії "Food", " food " і "FOOD" — різні рядки, а отже різні ключі й різні групи. Коли це спливає у звіті, здається, що «groupBy зламався», але насправді ви просто не нормалізували ключ. Зазвичай достатньо trim().lowercase() прямо в keySelector.
Помилка № 4: намагатися читати групу через groups[key]!! і ловити падіння.
Навіть якщо «ви впевнені, що ключ є», код з часом змінюється, вхідні дані стають бруднішими — і от ви вже ловите NullPointerException через !!. Значно спокійніше або обходити мапу повністю через for ((k, v) in groups), або використовувати groups[key] ?: emptyList().
Помилка № 5: друкувати println(groups) і очікувати «гарний звіт».
println(map) — швидкий спосіб переконатися, що структура вийшла, і для налагодження він нормальний. Але в реальній програмі це перетворює вивід на «кашу з фігурних дужок». Краще зробити невеликий форматований вивід: категорія — заголовком, потім елементи групи построково. Це буквально 10 рядків коду, а читабельність зростає на порядок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ