1. Вступ
Коли ви пишете навчальний проєкт, дуже легко застрягти на командах «додай запис / покажи список / видали запис» і радіти, що воно бодай не падає. Але справжня «магія користі» починається тоді, коли ви вмієте відповідати на запитання користувача: «А що в мене відбувається?». Групування — це як узяти коробку з деталями Lego й розкласти їх по контейнерах: червоні — окремо, сині — окремо, а круглі хай узагалі живуть своїм життям.
У нашому практичному консольному проєкті (умовний трекер витрат) дані живуть у колекції. Отже, звіти теж мають бути «про колекції»: без ручних вкладених циклів на три екрани й без стану, який потім важко налагоджувати. Сьогодні ми акуратно поєднаємо прийоми дня з проєктною логікою. Один раз навчимося робити «список за ключем» і «лічильник за ключем» — і далі ви почнете помічати ці дві форми результату майже в будь-якій задачі.
2. Міні‑модель даних: запис витрати
Щоб показувати звіти, нам потрібні дані. Оскільки класи та data class у нас будуть пізніше, зараз використаємо просту структуру на Triple. Домовмося, що один запис витрати — це:
- категорія (String)
- сума (Int)
- коментар (String)
Одразу зробімо маленьку утиліту, щоб у прикладах код був читабельним. Зверніть увагу: це не новий тип, а просто функція, яка створює Triple.
fun expense(category: String, amount: Int, note: String): Triple<String, Int, String> {
return Triple(category, amount, note)
}
Тепер можна зібрати тестові дані:
fun main() {
val expenses = listOf(
expense("food", 120, "groceries"),
expense("food", 80, "coffee"),
expense("taxi", 250, "airport"),
expense("books", 500, "kotlin")
)
println(expenses.size) // 4
}
Головна думка: у нас є плоский список записів. А звіти майже завжди хочуть бачити структуру: або «розділи» (групи), або «зведення» (частоти).
3. Дві форми результату: список і лічильник за ключем
Зараз буде важливий момент: вам не потрібно запам’ятовувати мільйон функцій. Достатньо тримати в голові лише дві «форми відповіді», які найчастіше потрібні бізнес-логіці. Вони відрізняються буквально тим, що саме лежить у значенні мапи.
Уявіть, що ключ — це «категорія витрати».
Тоді маємо два класичні результати:
| Що хочемо отримати | Який вигляд має результат | Як зазвичай робимо |
|---|---|---|
| «Покажи всі витрати за категоріями» | |
|
| «Скільки витрат у кожній категорії?» | |
|
groupBy повертає мапу, де ключ — результат лямбди, а значення — список елементів групи.
eachCount() повертає мапу, де значення — це просто кількість елементів для ключа.
Щоб мозок не перегрівався, тримайте в голові просту аналогію: groupBy — це «папки з файлами», а eachCount — це «скільки файлів у кожній папці».
Невелика схема, як це сприймати:
flowchart TD
A["List⟨ExpenseRecord⟩ (плоский список)"] --> B["groupBy { category }"]
B --> C["Map⟨Category, List⟨ExpenseRecord⟩⟩ (деталі)"]
A --> D["groupingBy { category }.eachCount()"]
D --> E["Map⟨Category, Int⟩ (зведення)"]
4. Практика: звіти за категоріями
Список за категорією через groupBy
Часто користувачеві потрібно не лише «скільки», а й «що саме». Наприклад: «Покажи, що в мене було в категорії "food"». Для цього потрібне групування з деталями — groupBy.
Згрупуймо за категорією. У записі витрати категорія лежить у first.
fun main() {
val expenses = listOf(
Triple("food", 120, "groceries"),
Triple("food", 80, "coffee"),
Triple("taxi", 250, "airport")
)
val byCategory: Map<String, List<Triple<String, Int, String>>> =
expenses.groupBy { it.first }
println(byCategory.keys) // [food, taxi]
}
Тут важливо звикнути до типу результату: Map<String, List<...>>. Це нормально, бо «в одній категорії може бути багато записів» — і саме тому в значенні лежить список.
Тепер давайте акуратно прочитаємо групи. Ми не хочемо друкувати мапу «як є» (вона не зобов’язана мати зручний порядок). Натомість зробімо зрозумілий вивід: категорія, а під нею — рядки.
fun main() {
val expenses = listOf(
Triple("food", 120, "groceries"),
Triple("food", 80, "coffee"),
Triple("taxi", 250, "airport")
)
val byCategory = expenses.groupBy { it.first }
for ((category, items) in byCategory) {
println("== $category ==") // == food == (порядок може відрізнятися)
println("елементів: ${items.size}") // елементів: 2
}
}
Навіть такий «напівзвіт» уже корисний: видно, що всередині кожної групи лежить звичайний список, з яким можна робити все знайоме — size, sortedBy, for і так далі.
Лічильник за категорією через groupingBy().eachCount()
Іноді деталі не потрібні. Наприклад, користувач запитує: «У яких категоріях у мене взагалі найбільше записів?». Тут список для кожної категорії не обов’язковий — нам потрібні лише числа.
І от тут groupingBy { ... }.eachCount() просто ідеальний варіант: він одразу робить «частотну мапу» Map<K, Int>.
fun main() {
val expenses = listOf(
Triple("food", 120, "groceries"),
Triple("food", 80, "coffee"),
Triple("taxi", 250, "airport")
)
val counts: Map<String, Int> =
expenses.groupingBy { it.first }.eachCount()
println(counts) // {food=2, taxi=1}
}
Тепер можна відсортувати за спаданням кількості й зробити «топ категорій». Ми пам’ятаємо патерн: беремо entries, сортуємо, друкуємо.
fun main() {
val counts = mapOf("food" to 2, "taxi" to 1, "books" to 5)
val sorted = counts.entries.sortedByDescending { it.value }
for ((category, count) in sorted) {
println("$category -> $count")
// books -> 5
// food -> 2
// taxi -> 1
}
}
Зверніть увагу, наскільки коротшим виходить код, якщо ви обрали правильну форму результату. Якщо вам потрібні числа — беріть Map<K, Int>. Якщо потрібні деталі — беріть Map<K, List<T>>.
Два підходи до підрахунку: через групи й напряму
Дуже типова ситуація в проєкті: на одних і тих самих даних ви хочете отримати два різні звіти.
Уявімо, що ми хочемо:
- «Список за категоріями» (деталі)
- «Кількість записів за категоріями» (зведення)
І от тут ви можете відчути різницю між двома підходами:
- Можна зробити groupBy, а потім перетворити групи на розміри через mapValues { ... }.
- Можна одразу зробити groupingBy().eachCount().
mapValues перетворює значення мапи, зберігаючи ключі. Це корисно, коли ви вже отримали групи й хочете додатково їх обробити.
Покажімо обидва варіанти поруч — не в сенсі «який кращий назавжди», а радше як підказку «що доречніше в конкретній ситуації».
fun main() {
val expenses = listOf(
Triple("food", 120, "groceries"),
Triple("food", 80, "coffee"),
Triple("taxi", 250, "airport")
)
val groups = expenses.groupBy { it.first }
val countsFromGroups = groups.mapValues { (_, items) -> items.size }
val countsDirect = expenses.groupingBy { it.first }.eachCount()
println(countsFromGroups) // {food=2, taxi=1}
println(countsDirect) // {food=2, taxi=1}
}
У житті вибір простий: якщо звіт справді потребує і деталей, і чисел, починайте з groupBy. А вже з отриманими групами можна робити що завгодно (наприклад, брати розмір, сортувати всередині групи, форматувати друк). Якщо ж звіт потребує лише чисел, то eachCount() позбавляє вас потреби зберігати списки.
Нормалізація ключа категорії
Зараз буде момент, на якому ламається половина звітів у новачків. Групування працює чесно: ключем вважається те, що ви повернули в лямбді. Якщо ви повернули "Taxi" і " taxi ", то для Kotlin це різні рядки. Відповідно, він створить дві різні групи або два різні записи в лічильнику.
Тому в проєкті ключ майже завжди потрібно нормалізувати: прибрати зайві пробіли й звести все до одного регістру. Одразу винесімо це у функцію, щоб не писати одне й те саме в п’яти місцях.
fun normalizeCategory(raw: String): String {
return raw.trim().lowercase()
}
Тепер використаємо її в групуванні:
fun main() {
val expenses = listOf(
Triple(" Taxi ", 250, "airport"),
Triple("taxi", 300, "home"),
Triple("TAXI", 150, "discount ride")
)
val counts = expenses.groupingBy { normalizeCategory(it.first) }.eachCount()
println(counts) // {taxi=3}
}
Це виглядає як дрібниця, але за відчуттями — ніби пристебнути ремінь безпеки: ви можете й без нього… рівно до першої неприємності.
5. Форматування та інтеграція в CLI
Форматування звітів і StringBuilder
Коли звіт уже «пораховано», виникає наступне доросле питання: «Як це гарно показати?». Друкувати Map напряму — спокусливо, але користувач не зобов’язаний любити формат {taxi=3, food=2} так само, як його любить Kotlin.
Тут допомагає дисципліна: дані рахуємо окремо, текст збираємо окремо.
Зробімо функцію, яка формує звіт «список за категорією». Категорії виведемо в алфавітному порядку — так зазвичай людині зрозуміліше.
import kotlin.text.StringBuilder
fun formatExpensesByCategory(groups: Map<String, List<Triple<String, Int, String>>>): String {
val sb = StringBuilder()
val sorted = groups.entries.sortedBy { it.key }
for ((category, items) in sorted) {
sb.appendLine("== $category (${items.size}) ==")
for ((_, amount, note) in items) {
sb.appendLine("- $amount : $note")
}
}
return sb.toString()
}
Перевіримо на маленькому прикладі:
fun main() {
val expenses = listOf(
Triple("food", 120, "groceries"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee")
)
val groups = expenses.groupBy { it.first }
val report = formatExpensesByCategory(groups)
println(report)
// == food (2) ==
// - 120 : groceries
// - 80 : coffee
// == taxi (1) ==
// - 250 : airport
}
Зверніть увагу на приємний ефект: основний main залишається «чистим» і коротким. А логіка форматування живе в окремій функції — і її набагато простіше змінювати, не боячись зламати введення й команди.
Тепер зробімо форматування для частотної мапи. Тут зазвичай хочеться сортувати за спаданням частоти й, можливо, показувати топ‑N. Ми вже знаємо take(n) із попередніх днів, тож використаємо його без сорому й докорів сумління.
import kotlin.text.StringBuilder
fun formatCategoryCounts(counts: Map<String, Int>, topN: Int): String {
val sb = StringBuilder()
val top = counts.entries
.sortedByDescending { it.value }
.take(topN)
for ((category, count) in top) {
sb.appendLine("$category -> $count")
}
return sb.toString()
}
Приклад:
fun main() {
val counts = mapOf("food" to 10, "taxi" to 3, "books" to 7)
val report = formatCategoryCounts(counts, topN = 2)
println(report)
// food -> 10
// books -> 7
}
Тут важливо розуміти: ми сортуємо не «саму мапу», а список записів entries. Це найзрозуміліший шлях: Map відповідає за відповідність ключ → значення, а «порядок виводу» ми задаємо самі — окремим кроком.
Під’єднуємо звіти до CLI
Зараз ми зробимо зв’язку з нашим консольним застосунком. Ми не пишемо весь проєкт повністю (інакше лекція перетвориться на серіал на 48 сезонів), але покажемо, як акуратно під’єднати два звіти до вже знайомої командної логіки.
Припустімо, у нас є список витрат MutableList<Triple<String, Int, String>> і цикл читання команд. Додамо дві команди:
- report byCategory — деталі за категоріями
- report counts — скільки записів у категоріях (топ‑N, наприклад топ‑5)
Скелет обробника:
fun handleReportCommand(expenses: List<Triple<String, Int, String>>, args: List<String>) {
val mode = args.getOrNull(0) ?: ""
when (mode) {
"byCategory" -> {
val groups = expenses.groupBy { normalizeCategory(it.first) }
println(formatExpensesByCategory(groups))
}
"counts" -> {
val counts = expenses.groupingBy { normalizeCategory(it.first) }.eachCount()
println(formatCategoryCounts(counts, topN = 5))
}
else -> {
println("Невідомий режим звіту: $mode") // Невідомий режим звіту: ...
}
}
}
І приклад того, «як це може викликатися» з основного циклу (спрощено). Зверніть увагу: ми припускаємо, що розбір рядка на токени (split, trim) у вас уже є з попередніх лекцій про команди.
fun main() {
val expenses = mutableListOf(
Triple("Food", 120, "groceries"),
Triple(" taxi ", 250, "airport"),
Triple("food", 80, "coffee")
)
val cmd = "report counts"
val parts = cmd.trim().split(" ").filter { it.isNotBlank() }
if (parts.firstOrNull() == "report") {
handleReportCommand(expenses, parts.drop(1))
}
}
Сенс тут не в тому, щоб ви запам’ятали «ідеальний формат команд», а в тому, щоб побачити архітектурну ідею: команда обирає звіт, а звіт обирає форму результату.
Якщо потрібна деталізація — groupBy і подальше форматування груп. Якщо потрібне зведення — eachCount і форматування частотної мапи.
6. Типові помилки
Помилка №1: обрати groupBy, коли потрібні лише числа, і потім дивуватися, що все стало «важким».
Новачки часто автоматично тягнуться до groupBy, бо він «зрозуміліший». Але результат Map<K, List<T>> зберігає цілі списки елементів. Якщо вам потрібно лише «скільки», це зайва робота й зайва пам’ять. У проєкті це проявляється так: ви робите групи, потім одразу берете .size і викидаєте деталі. У таких випадках краще відразу використати groupingBy { ... }.eachCount() і отримати Map<K, Int>.
Помилка №2: не нормалізувати ключ і отримати категорії, що «роз’їхалися».
Якщо користувач вводить категорії вручну, ви майже гарантовано отримаєте "Food", " food", "FOOD" як різні ключі. Групування працює правильно, але звіт виглядає так, ніби в користувача три різні категорії. Лікується це тим, що нормалізація (trim().lowercase()) виконується всередині selector ключа, а не «десь потім», коли вже пізно.
Помилка №3: читати групи через groups[key]!! і ловити падіння на відсутньому ключі.
Коли ви читаєте значення з Map, Kotlin справедливо повертає V?, бо ключ може бути відсутній. Якщо ви у звіті робите groups["food"]!!, а категорії "food" немає (або вона стала " Food " і не нормалізована), ви отримаєте NullPointerException саме там, де користувач хотів «просто звіт». У звітній логіці краще обходити entries або використовувати безпечні варіанти на кшталт groups[key].orEmpty().
Помилка №4: друкувати Map напряму й очікувати «гарного» порядку.
Часто хочеться зробити println(counts) і вважати задачу завершеною. Але порядок у виводі мапи не є частиною контракту «зрозумілий звіт». Користувач зазвичай хоче або сортування за ключем (алфавіт), або сортування за значенням (за спаданням частоти). Тому нормальний шлях — counts.entries.sortedBy... і лише потім друк.
Помилка №5: змішати обчислення та форматування в один гігантський ланцюжок.
Ланцюжки операцій колекцій — круті, доки ви можете їх прочитати. Коли ви в один рядок запхали нормалізацію, групування, сортування, take, збирання рядків і друк, виходить «код-головоломка», який важко змінювати. Набагато надійніше розділяти на 2–3 проміжні val: counts, sorted, reportText. Так ви і собі допоможете, і налагодження буде простішим.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ