1. Зачем нужна группировка
Когда данных становится больше, простой список начинает вести себя как шкаф без полок: вещи вроде бы есть, но найти «все футболки» среди «всего подряд» трудно. Группировка — это способ взять плоский список и разложить элементы по «корзинам» (группам) по некоторому признаку: по категории, по первой букве, по длине строки, по году и так далее.
В Kotlin группировку чаще всего делают функцией groupBy. Её идея проста: для каждого элемента вычисляется ключ группировки, и элементы собираются в карту: ключ → список элементов, у которых получился этот ключ.
Представьте, что у вас есть расходы:
- "food" 120 "lunch"
- "taxi" 250 "airport"
- "food" 80 "coffee"
Вы хотите получить структуру вида:
- food → [...все расходы food...]
- taxi → [...все расходы taxi...]
Это и есть результат groupBy.
2. groupBy: ключи и результат Map<K, List<T>>
Если читать «по-взрослому», groupBy делает две вещи: вычисляет ключ для каждого элемента и собирает элементы в группы по этому ключу. Важно заранее принять главную мысль дня: результат — не Map<K, T>, а именно Map<K, List<T>>, потому что элементов с одним и тем же ключом может быть много.
Форма результата выглядит так:
- вход: List<T>
- выход: Map<K, List<T>>
Где K — тип ключа, который получился из вашей лямбды (например, Int, Char, String), а T — тип элементов исходного списка.
Небольшой пример на словах (самый «чистый», без нашего проекта):
fun main() {
val words = listOf("one", "two", "three", "four")
val byLength: Map<Int, List<String>> = words.groupBy { it.length }
println(byLength) // {3=[one, two], 5=[three], 4=[four]}
}
Здесь ключ — длина слова (Int), а значение — список слов этой длины.
И да, groupBy — это стандартный и очень «каноничный» приём Kotlin-стиля; он часто показывается как базовый инструмент работы с коллекциями.
Почему в значении именно List
Момент, который у новичков вызывает лёгкое «погодите…»: почему Kotlin не возвращает что-то вроде Map<K, Set<T>>? Почему именно List?
Ответ практичный: groupBy сохраняет все элементы и сохраняет их в порядке исходного списка. То есть внутри каждой группы порядок будет таким же, как элементы встретились в оригинале. Это удобно, потому что часто вы уже отсортировали исходные данные, или вам важен порядок добавления, или вы просто хотите предсказуемый вывод.
Плюс List — самый «мягкий» контейнер. Он не требует уникальности (в группе могут быть одинаковые элементы), не заставляет думать про хэширование, и в целом хорошо соответствует идее «собрали всё, как есть, потом разберёмся».
Если вам вдруг нужен другой контейнер (например, Set), это уже следующий шаг обработки: сначала сгруппировали, потом преобразовали. Но сегодня мы как раз тренируемся получать и читать базовую форму Map<K, List<T>>.
Как Kotlin решает, что считать «одинаковым»
Ключ группировки — это результат вашей лямбды keySelector, то есть выражения в фигурных скобках. Kotlin вычисляет его для каждого элемента и использует как «ярлык корзины», куда этот элемент попадёт.
Пример: группируем слова по первой букве.
fun main() {
val words = listOf("one", "two", "three", "four")
val byFirstChar: Map<Char, List<String>> = words.groupBy { it[0] }
for ((ch, group) in byFirstChar) {
println("$ch -> $group")
}
// o -> [one]
// t -> [two, three]
// f -> [four]
}
Заметьте, тип ключа тут Char. Kotlin выводит его из выражения it[0].
И вот здесь появляется первая «взрослая» осторожность: если строка может быть пустой, it[0] упадёт с ошибкой. Поэтому при группировке по символам почти всегда сначала фильтруют пустые строки.
3. Как читать результат groupBy
После groupBy вы получаете Map. А значит, чтение результата происходит ровно теми же способами, что и любая карта: через обход entries (или через деструктуризацию в for), либо через доступ по ключу.
Напомню важную деталь: Map хранит пары ключ-значение, ключи уникальны, а чтение по ключу возвращает nullable-значение, потому что ключ может отсутствовать. Это напрямую влияет на то, как безопасно читать группы.
Самый приятный способ — просто обойти все группы:
fun main() {
val words = listOf("one", "two", "three", "four")
val groups = words.groupBy { it.length }
for ((len, group) in groups) {
println("len=$len, count=${group.size}, items=$group")
// len=3, count=2, items=[one, two]
// len=5, count=1, items=[three]
// len=4, count=1, items=[four]
}
}
Здесь group — это обычный List<String>. У него есть size, его можно перебирать, сортировать (если очень надо), строить из него строки и так далее.
Если же вы хотите взять «конкретную» группу по ключу, нужно помнить про nullable:
fun main() {
val words = listOf("one", "two", "three", "four")
val groups = words.groupBy { it.length }
val len4: List<String> = groups[4] ?: emptyList()
println(len4) // [four]
}
Это один из самых спокойных паттернов: «если группы нет — считаем, что она пустая». Он особенно удобен в отчётах.
4. BudgetBuddy: группируем расходы по категориям
Теперь давайте сделаем то, ради чего всё затевалось: отчёт «расходы по категориям». В этом месте важно не прыгать в космос, а аккуратно соединить знания из предыдущих тем с новой функцией.
У нас есть список расходов:
- category: String
- amount: Int (пока целые числа, чтобы не спорить с копейками и Double)
- note: String
Опишем тип записи как Triple и подготовим тестовые данные:
fun main() {
val expenses: List<Triple<String, Int, String>> = listOf(
Triple("food", 120, "lunch"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee"),
Triple("books", 500, "kotlin book")
)
val byCategory = expenses.groupBy { it.first }
println(byCategory.keys) // [food, taxi, books]
}
Ключ тут — it.first, то есть категория. А значение — список троек, которые относятся к этой категории.
Тут полезно заметить, как быстро «по ощущениям» становится легче работать: вместо одного длинного списка у нас появилась структура, в которой данные уже «разложены по полочкам».
Печатаем отчёт по группам
Хороший отчёт — это не просто println(map) (хотя иногда и это уже счастье). Обычно хочется вывести блоками: сначала категория, затем элементы внутри неё.
Сделаем функцию печати «секций». Заметьте: мы не делаем сложную архитектуру и не лезем в будущие темы; просто аккуратно используем groupBy и циклы.
fun printByCategoryReport(expenses: List<Triple<String, Int, String>>) {
val groups = expenses.groupBy { it.first }
for ((category, items) in groups) {
println("== $category ==")
for ((_, amount, note) in items) {
println("- $amount : $note")
}
println()
}
}
И пример вызова:
fun main() {
val expenses = listOf(
Triple("food", 120, "lunch"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee")
)
printByCategoryReport(expenses)
// == food ==
// - 120 : lunch
// - 80 : coffee
//
// == taxi ==
// - 250 : airport
}
Обратите внимание на маленький UX-момент: мы сделали пустую строку между категориями. Это мелочь, но она превращает «лог» в «отчёт».
5. Полезные нюансы groupBy
Нормализация ключа
Когда вы впервые внедряете группировки в реальное приложение, внезапно выясняется, что пользователи (и вы сами) вводят категории как попало: "Food", " food ", "FOOD". Если группировать «как есть», вы получите три группы вместо одной. И это не баг groupBy, это честный результат: ключи-то разные.
Поэтому ключ группировки часто делают нормализованным: trim() + lowercase().
fun normalizeCategory(raw: String): String {
return raw.trim().lowercase()
}
fun main() {
val expenses = listOf(
Triple(" Food ", 120, "lunch"),
Triple("food", 80, "coffee"),
Triple("FOOD", 200, "dinner")
)
val groups = expenses.groupBy { normalizeCategory(it.first) }
println(groups.keys) // [food]
}
Заметьте идею: нормализация — часть keySelector. То есть мы не «поправили потом», а сразу договорились, что ключи считаются по смыслу.
groupBy(keySelector, valueTransform)
Иногда вам не нужны «полные записи» в группе. Например, для отчёта по категориям вы хотите хранить внутри группы только суммы (amount), чтобы потом считать агрегаты. Или только заметки. Или, например, только «человеческий текст», готовый к печати.
Для таких случаев у groupBy есть форма с двумя лямбдами: одна определяет ключ, вторая преобразует элемент перед тем, как положить его в группу.
Идея звучит так: «сгруппируй по ключу, но в каждой группе храни не T, а V».
Пример: сгруппируем расходы по категории, но положим внутрь только amount.
fun main() {
val expenses = listOf(
Triple("food", 120, "lunch"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee")
)
val amountsByCategory: Map<String, List<Int>> = expenses.groupBy(
keySelector = { it.first },
valueTransform = { it.second }
)
println(amountsByCategory) // {food=[120, 80], taxi=[250]}
}
Это всё ещё Map<K, List<...>>, просто теперь внутри списка лежит другой тип (Int), а не исходная тройка.
Точно так же можно хранить заранее подготовленные строки для печати:
fun main() {
val expenses = listOf(
Triple("food", 120, "lunch"),
Triple("food", 80, "coffee")
)
val linesByCategory = expenses.groupBy(
keySelector = { it.first },
valueTransform = { (_, amount, note) -> "$amount : $note" }
)
println(linesByCategory["food"])
// [120 : lunch, 80 : coffee]
}
Этот приём хорош, когда вы хотите сделать вывод проще и не тащить дальше «тяжёлые» структуры.
Мини-схема: что делает groupBy
Чтобы закрепить «форму результата», полезно один раз увидеть это как процесс. Это не магия и не «особая структура данных», а вполне понятная перекладка элементов.
flowchart TD
A["List⟨T⟩ (плоский список)"] -->|для каждого элемента считаем key| B["ключ K"]
B --> C["Map⟨K, List⟨T⟩⟩"]
A -->|элемент добавляется в список своей группы| C
То есть groupBy не «теряет элементы» и не «считает статистику». Он именно раскладывает элементы по группам, сохраняя их как списки.
6. Типичные ошибки при работе с groupBy
Ошибка №1: ожидать Map<K, V>, а получить Map<K, List<V>>.
Это самая частая путаница. Мозг новичка иногда хочет «категория → один расход», но группировка по определению делает «категория → много расходов». Если вам правда нужен один элемент на ключ, это уже другая задача (и другой инструмент). А для groupBy полезно мысленно проговаривать: «группа — это список».
Ошибка №2: группировать по it[0], не убедившись, что строка не пустая.
groupBy тут не виноват: вы сами обращаетесь к нулевому символу. В реальном вводе пустые строки появляются постоянно (двойные пробелы, пустые команды, случайный Enter). Спасает дисциплина: перед группировкой фильтровать пустое или выбирать ключ, который не требует рискованного доступа по индексу.
Ошибка №3: получать «двойные» категории из-за пробелов и регистра.
Категории "Food", " food " и "FOOD" — разные строки, а значит разные ключи, а значит разные группы. Когда это всплывает в отчёте, кажется, что «groupBy сломался», но на самом деле вы просто не нормализовали ключ. Обычно достаточно trim().lowercase() прямо в keySelector.
Ошибка №4: пытаться читать группу через groups[key]!! и ловить падение.
Даже если «вы уверены, что ключ есть», код со временем меняется, входные данные становятся грязнее, и вот вы уже ловите NullPointerException из-за !!. Гораздо спокойнее либо обходить карту целиком через for ((k, v) in groups), либо использовать groups[key] ?: emptyList().
Ошибка №5: печатать println(groups) и ожидать «красивый отчёт».
println(map) — это быстрый способ увидеть, что структура получилась, и для отладки он нормальный. Но в реальной программе это превращает вывод в «кашу фигурных скобок». Лучше сделать маленький форматированный вывод: категория заголовком, затем элементы группы построчно. Это буквально 10 строк кода, а читабельность вырастает на порядок.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ