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: ${items.size}")       // items: 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("Unknown report mode: $mode") // Unknown report 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
Задача
Kotlin SELF, 24 уровень, 4 лекция
Недоступна
Категории трат
Категории трат
1
Задача
Kotlin SELF, 24 уровень, 4 лекция
Недоступна
Частоты из строки
Частоты из строки
1
Задача
Kotlin SELF, 24 уровень, 4 лекция
Недоступна
Нормализация категорий
Нормализация категорий
1
Задача
Kotlin SELF, 24 уровень, 4 лекция
Недоступна
Красивый отчёт
Красивый отчёт
1
Опрос
Группировки и частоты, 24 уровень, 4 лекция
Недоступен
Группировки и частоты
Группировки и частоты
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ