1. Навіщо потрібна постобробка результатів
Коли ви вперше отримуєте результат groupBy або eachCount, легко виникає відчуття: «О, ось же дані! Зараз println(map) — і готово». І так, технічно це буде виведення. Але якщо ви бодай раз читали журнал сервера в пʼятницю ввечері (коли хочеться просто жити), то знаєте: «виведення» і «звіт» — це два різні психологічні стани.
Постобробка — це крок, на якому ми розвʼязуємо дві задачі. По-перше, на що перетворити значення (наприклад, список → розмір списку). По-друге, у якому порядку показувати результат (за ключем, за частотою, top‑N). В ідеалі ми ще й приводимо виведення до зручного вигляду: вирівнюємо колонки, додаємо заголовки й робимо так, щоб звіт читався очима, а не лише компілятором.
Уявімо, що ми продовжуємо наш консольний міні‑застосунок зі списком витрат (тримаємо їх у памʼяті, без класів і без бази даних — поки все чесно й просто). Нехай витрата — це пара (category, amount):
fun main() {
val expenses = listOf(
"food" to 120,
"food" to 80,
"taxi" to 250,
"books" to 600,
"taxi" to 140
)
println(expenses) // [(food, 120), (food, 80), (taxi, 250), (books, 600), (taxi, 140)]
}
Список нормальний. Але щойно ми хочемо «звіт за категоріями», починається життя: потрібні групування, лічильники, сортування, а вже потім — текст.
Як обрати: eachCount() чи groupBy().mapValues { size }
Коли ви бачите ось такий код:
val counts = list.groupBy { key }.mapValues { it.value.size }
виникає резонне запитання: «А навіщо взагалі eachCount, якщо можна так?» І навпаки: «Навіщо групувати в списки, якщо нам потрібні лише числа?»
Щоб не плутатися, корисно тримати в голові маленьку порівняльну табличку:
| Задача | Інструмент | Що виходить | Коли зручно |
|---|---|---|---|
| Показати деталі всередині кожної групи | |
|
Коли ви справді будете обходити елементи групи |
| Порахувати «скільки елементів» на ключ | |
|
Коли список елементів групи взагалі не потрібен |
Уже є , але тепер захотіли зведення |
|
|
Коли ви не хочете перебудовувати ланцюжок обробки заново |
Отже, eachCount() — це пряміший шлях до лічильників. А mapValues { size } — рятівне коло, коли у вас уже є групи й ви хочете «вичавити» з них зведені числа.
2. mapValues: перетворюємо значення мапи
Коли ви робите groupBy, ви майже гарантовано отримуєте мапу, де значення — списки: Map<K, List<T>>. Це логічно: група — це набір елементів. Але звіт часто хоче від групи не весь набір, а «похідне» значення: розмір групи, середнє, список назв, перше/останнє тощо.
Для таких перетворень існує mapValues: функція, яка залишає ключі як є, але змінює значення. І це пряме продовження ідеї «ланцюжків перетворень колекцій»: більшість операцій створюють новий результат і не змінюють вихідний обʼєкт.
Найчастіший сценарій: «хочу розмір групи»
Згрупуємо витрати за категорією:
fun main() {
val expenses = listOf(
"food" to 120,
"food" to 80,
"taxi" to 250,
"books" to 600,
"taxi" to 140
)
val byCategory: Map<String, List<Pair<String, Int>>> =
expenses.groupBy { it.first }
println(byCategory["taxi"]) // [(taxi, 250), (taxi, 140)]
}
Тепер хочемо мапу «категорія → кількість записів»:
fun main() {
val expenses = listOf(
"food" to 120,
"food" to 80,
"taxi" to 250,
"books" to 600,
"taxi" to 140
)
val countsByCategory: Map<String, Int> = expenses
.groupBy { it.first }
.mapValues { entry -> entry.value.size }
println(countsByCategory) // {food=2, taxi=2, books=1}
}
Тут важливо відчути механіку: mapValues отримує entry (пару key+value), і entry.value — це той самий список елементів групи, а size — просто його довжина.
Якщо вам зручніше читати через деконструкцію, можна так:
fun main() {
val expenses = listOf("food" to 120, "food" to 80, "taxi" to 250)
val countsByCategory = expenses
.groupBy { it.first }
.mapValues { (_, group) -> group.size }
println(countsByCategory) // {food=2, taxi=1}
}
mapKeys — поруч, але сьогодні не потрібен
Іноді хочеться «поправити ключі» (наприклад, привести до нижнього регістру). Для цього є mapKeys, але сьогодні наш фокус — саме на постобробці результатів групування. А там частіше змінюють значення: список → число, список → рядок, число → рядок тощо.
3. Сортування: працюємо з entries
Після підрахунку частот виникає друга болюча точка початківця: «Хочу вивести гарно: спочатку найчастіші категорії». І тут трапляється типова сцена з життя: ви намагаєтеся «відсортувати Map», компілятор дивиться на вас із легкою журбою, і ви розумієте, що Map — це не «впорядкований список рядків», а структура ключ → значення.
Правильний хід такий: для сортування ми майже завжди беремо entries (набір записів key/value) і перетворюємо його на список. А список уже можна сортувати й виводити в потрібному порядку.
Сортування за ключем
fun main() {
val counts = mapOf("taxi" to 2, "food" to 5, "books" to 1)
val sortedByKey = counts.entries.sortedBy { it.key }
for (e in sortedByKey) {
println("${e.key} -> ${e.value}")
// books -> 1
// food -> 5
// taxi -> 2
}
}
Тут ми сортуємо за it.key. Це зручно для «довідкового» звіту: усе за абеткою, легко шукати очима.
Сортування за значенням за спаданням
fun main() {
val counts = mapOf("taxi" to 2, "food" to 5, "books" to 1)
val sortedByCountDesc = counts.entries.sortedByDescending { it.value }
for (e in sortedByCountDesc) {
println("${e.key} -> ${e.value}")
// food -> 5
// taxi -> 2
// books -> 1
}
}
Top‑N через take(n)
Після сортування легко обрати перші N записів:
fun main() {
val counts = mapOf("taxi" to 2, "food" to 5, "books" to 1, "games" to 4)
val top2 = counts.entries
.sortedByDescending { it.value }
.take(2)
println(top2.map { it.key to it.value }) // [(food, 5), (games, 4)]
}
take(n) — базова операція «взяти перші N елементів» у списку.
4. Підготовка виведення: рядки та StringBuilder
Сортування й mapValues дають нам структуру даних, але «фінальна форма звіту» — це вже рядок (або набір рядків). І тут дуже хочеться не ліпити все одним величезним println(...), а зробити код читабельним: окремо підготувати рядки звіту й окремо вивести їх.
Це особливо корисно в нашому консольному застосунку. Сьогодні ви друкуєте в консоль, завтра захочете повернути рядок із функції, а післязавтра (у далекому світлому майбутньому) — записати у файл. Коли логіка формування рядків відокремлена, жити простіше.
Список рядків → склеювання через joinToString
fun main() {
val counts = mapOf("food" to 5, "taxi" to 2, "books" to 1)
val lines = counts.entries
.sortedByDescending { it.value }
.map { "${it.key}: ${it.value}" }
println(lines.joinToString(separator = "\n"))
// food: 5
// taxi: 2
// books: 1
}
Вже непогано: читабельно, передбачувано. Але іноді потрібно більше контролю: заголовок, нумерація, вирівнювання.
StringBuilder: коли звіт — це багаторядковий документ
StringBuilder ми вже використовували раніше для збирання великих рядків без нескінченної конкатенації. А приємний бонус Kotlin — у StringBuilder є зручні функції-розширення на кшталт appendLine, які роблять складання багаторядкового тексту майже «літературним».
Зберемо простий звіт із заголовком:
import kotlin.text.appendLine
fun main() {
val counts = mapOf("food" to 5, "taxi" to 2, "books" to 1)
val report = StringBuilder()
.appendLine("Витрати за категоріями (кількість)")
.appendLine("-------------------------------")
.apply {
for (e in counts.entries.sortedByDescending { it.value }) {
appendLine("${e.key}: ${e.value}")
}
}
.toString()
println(report)
// Витрати за категоріями (кількість)
// -------------------------------
// food: 5
// taxi: 2
// books: 1
}
Зверніть увагу: сьогодні ми не обговорюємо scope‑функції як тему. Але apply тут можна сприймати просто як «зручний блок, щоб дописати в builder». Якщо вам поки простіше, можна й без apply:
import kotlin.text.appendLine
fun main() {
val counts = mapOf("food" to 5, "taxi" to 2)
val sb = StringBuilder()
sb.appendLine("Звіт")
for (e in counts.entries.sortedByDescending { it.value }) {
sb.appendLine("${e.key}: ${e.value}")
}
println(sb.toString())
// Звіт
// food: 5
// taxi: 2
}
Вирівнювання колонок через padEnd і padStart
Іноді звіт читається в рази краще, якщо числа стоять рівно під числами. Для цього зручно використовувати padEnd/padStart.
import kotlin.text.appendLine
fun main() {
val counts = mapOf("food" to 12, "taxi" to 2, "books" to 100)
val sb = StringBuilder()
sb.appendLine("Категорія Кількість")
sb.appendLine("-------------------------")
for (e in counts.entries.sortedByDescending { it.value }) {
val category = e.key.padEnd(14)
val count = e.value.toString().padStart(5)
sb.appendLine("$category$count")
}
println(sb.toString())
// Категорія Кількість
// -------------------------
// books 100
// food 12
// taxi 2
}
Це не «косметика заради косметики». Це реальна турбота про майбутнього себе: коли звіт великий, вирівнювання знижує навантаження на мозок.
5. Практичний приклад: звіт за категоріями
Тепер зробимо цілісний шматок логіки, який схожий на реальний крок у проєкті: у нас є список витрат, і ми хочемо отримати звіт «скільки записів на категорію» у відсортованому вигляді.
Спочатку — функція підготовки даних. Ми не будемо ускладнювати модель: усе ще Pair<String, Int>.
fun buildCategoryCounts(expenses: List<Pair<String, Int>>): Map<String, Int> {
return expenses
.groupBy { (category, _) -> category.trim().lowercase() }
.mapValues { (_, group) -> group.size }
}
Тепер — функція, яка перетворює мапу на зрозумілий текст:
import kotlin.text.appendLine
fun formatCategoryCounts(counts: Map<String, Int>, topN: Int): String {
val sorted = counts.entries
.sortedByDescending { it.value }
.take(topN)
val sb = StringBuilder()
sb.appendLine("Топ категорій за кількістю витрат")
sb.appendLine("--------------------------------")
for ((category, count) in sorted) {
sb.appendLine("${category.padEnd(15)} ${count.toString().padStart(4)}")
}
return sb.toString()
}
І нарешті — main, який демонструє весь ланцюжок:
fun main() {
val expenses = listOf(
" Food " to 120,
"food" to 80,
"taxi" to 250,
"BOOKS" to 600,
"taxi" to 140,
"food" to 30
)
val counts = buildCategoryCounts(expenses)
val report = formatCategoryCounts(counts, topN = 3)
println(report)
// Топ категорій за кількістю витрат
// --------------------------------
// food 3
// taxi 2
// books 1
}
Тут ми зробили рівно те, про що говорили: постобробка результатів групування перетворює дані на форму, придатну для читання людиною. І так, ця людина — ви за тиждень.
6. Типові помилки під час постобробки Map
Помилка № 1: очікувати, що mapValues «змінить мапу на місці».
Початківці часто мислять так: «Я викликав mapValues, значить мапа оновилася». Але більшість операцій з колекціями в Kotlin повертають новий результат, а вихідні колекції не чіпають. Тому якщо ви зробили counts.mapValues{...} і ніде не зберегли результат, ви просто порахували в порожнечу — ніби написали звіт і забули натиснути «Зберегти».
Помилка № 2: намагатися сортувати Map, а не entries.
Сортування — це операція для впорядкованої послідовності. Map сам по собі — не те місце, де «зобовʼязаний» бути порядок виведення, а друк мапи не має бути звітом. Робоча звичка така: для виведення беремо map.entries, сортуємо це як список записів (за ключем або за значенням) і вже його друкуємо. Це розвʼязує і проблему порядку, і проблему «чому виведення щоразу трохи інше».
Помилка № 3: робити забагато логіки прямо всередині mapValues { ... }.
mapValues зручний, але якщо всередині лямбди у вас пів екрана коду з циклами, перевірками й форматуванням, читання перетворюється на квест. У таких випадках код стає зрозумілішим, якщо винести складне обчислення значення в окрему функцію (наприклад, fun groupSum(group: List<Pair<String, Int>>): Int) і в mapValues залишити один короткий рядок.
Помилка № 4: відсортувати результат, а потім надрукувати вихідну мапу.
Буває кумедна ситуація: ви акуратно зробили val sorted = map.entries.sortedByDescending{...}, а потім десь нижче випадково написали println(map). У підсумку в консолі все не відсортовано, ви починаєте підозрювати Kotlin у змові, хоча «змова» була на боці одного рядка. Хороша звичка: для звіту друкувати тільки те, що ви справді підготували (зазвичай це sorted або вже готовий рядок report).
Помилка № 5: сподіватися, що «і так зійде» без нормалізації ключів.
Якщо ключі приходять із введення користувача або з «брудних» рядків, то "Food", " food " і "FOOD" стануть трьома різними ключами. А ваш «топ категорій» виглядатиме так, ніби ви харчувалися у трьох різних всесвітах. Нормалізація (trim().lowercase()) має відбуватися до групування й до підрахунків. Інакше ви гарно відсортуєте й виведете… неправильну статистику.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ