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 есть удобные extension‑функции вроде appendLine, которые делают сборку многострочного текста почти «литературной».

Соберём простой отчёт с заголовком:

import kotlin.text.appendLine

fun main() {
    val counts = mapOf("food" to 5, "taxi" to 2, "books" to 1)

    val report = StringBuilder()
        .appendLine("Expenses by category (count)")
        .appendLine("---------------------------")
        .apply {
            for (e in counts.entries.sortedByDescending { it.value }) {
                appendLine("${e.key}: ${e.value}")
            }
        }
        .toString()

    println(report)
    // Expenses by category (count)
    // ---------------------------
    // 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("Report")
    for (e in counts.entries.sortedByDescending { it.value }) {
        sb.appendLine("${e.key}: ${e.value}")
    }

    println(sb.toString())
    // Report
    // 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("Category        Count")
    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())
    // Category        Count
    // ---------------------
    // 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("Top categories by number of expenses")
    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)
    // Top categories by number of expenses
    // -----------------------------------
    // 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()) должна происходить до группировки и до подсчётов — иначе вы красиво отсортируете и выведете… неправильную статистику.

1
Задача
Kotlin SELF, 24 уровень, 2 лекция
Недоступна
Буквенная сводка
Буквенная сводка
1
Задача
Kotlin SELF, 24 уровень, 2 лекция
Недоступна
Средние оценки
Средние оценки
1
Задача
Kotlin SELF, 24 уровень, 2 лекция
Недоступна
Отчёт по алфавиту
Отчёт по алфавиту
1
Задача
Kotlin SELF, 24 уровень, 2 лекция
Недоступна
Лидерборд продаж
Лидерборд продаж
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ