JavaRush /Курси /Kotlin SELF /Групування у звітах: список за ключем і лічильник за ключ...

Групування у звітах: список за ключем і лічильник за ключем

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

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. Дві форми результату: список і лічильник за ключем

Зараз буде важливий момент: вам не потрібно запам’ятовувати мільйон функцій. Достатньо тримати в голові лише дві «форми відповіді», які найчастіше потрібні бізнес-логіці. Вони відрізняються буквально тим, що саме лежить у значенні мапи.

Уявіть, що ключ — це «категорія витрати».

Тоді маємо два класичні результати:

Що хочемо отримати Який вигляд має результат Як зазвичай робимо
«Покажи всі витрати за категоріями»
Map<String, List<Triple<...>>>
groupBy { category }
«Скільки витрат у кожній категорії?»
Map<String, Int>
groupingBy { category }.eachCount()

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>>.

Два підходи до підрахунку: через групи й напряму

Дуже типова ситуація в проєкті: на одних і тих самих даних ви хочете отримати два різні звіти.

Уявімо, що ми хочемо:

  • «Список за категоріями» (деталі)
  • «Кількість записів за категоріями» (зведення)

І от тут ви можете відчути різницю між двома підходами:

  1. Можна зробити groupBy, а потім перетворити групи на розміри через mapValues { ... }.
  2. Можна одразу зробити 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. Так ви і собі допоможете, і налагодження буде простішим.

1
Опитування
Групування та частоти, рівень 24, лекція 4
Недоступний
Групування та частоти
Групування та частоти
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ