JavaRush /Курсы /Kotlin SELF /groupBy: получаем Map<K, List<V>> и читаем гр...

groupBy: получаем Map<K, List<V>> и читаем группы

Kotlin SELF
24 уровень , 0 лекция
Открыта

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 строк кода, а читабельность вырастает на порядок.

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