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 есть удобные 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()) должна происходить до группировки и до подсчётов — иначе вы красиво отсортируете и выведете… неправильную статистику.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ