JavaRush /Курси /Kotlin SELF /Постобробка результатів — mapValues, сортування та підгот...

Постобробка результатів — mapValues, сортування та підготовка виведення

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

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, якщо можна так?» І навпаки: «Навіщо групувати в списки, якщо нам потрібні лише числа?»

Щоб не плутатися, корисно тримати в голові маленьку порівняльну табличку:

Задача Інструмент Що виходить Коли зручно
Показати деталі всередині кожної групи
groupBy
Map<K, List<T>>
Коли ви справді будете обходити елементи групи
Порахувати «скільки елементів» на ключ
groupingBy().eachCount()
Map<K, Int>
Коли список елементів групи взагалі не потрібен
Уже є
groupBy
, але тепер захотіли зведення
mapValues { size }
Map<K, Int>
Коли ви не хочете перебудовувати ланцюжок обробки заново

Отже, 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()) має відбуватися до групування й до підрахунків. Інакше ви гарно відсортуєте й виведете… неправильну статистику.

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