JavaRush /Курсы /Kotlin SELF /Архитектура отчёта как пайплайна

Архитектура отчёта как пайплайна

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

1. Отчёт как пайплайн

Когда вы слышите слово «отчёт», мозг новичка часто рисует что-то вроде: «взяли список → пробежались → напечатали». Это рабочая схема… ровно до первого требования «сделай ещё top‑3 категории», «а ещё отфильтруй только суммы ≥ X», «а ещё сделай так, чтобы “Food”, “ food ” и “FOOD” были одной категорией». В этот момент код начинает напоминать клубок проводов под столом: вроде работает, но трогать страшно.

Поэтому нам нужен другой взгляд: отчёт — это пайплайн.

Пайплайн — это когда мы не делаем «всё сразу», а строим цепочку шагов:

входные данные → преобразования → итоговые данные → текст отчёта

Важно: отчёт почти всегда начинается с «сыроватых» данных (то, как пользователь вводил категории, суммы, пробелы, регистр букв), а заканчивается чем-то «официальным»: числами, структурами, отсортированными строками.

Давайте зафиксируем базовую мысль дня:

Отчёт — это не “одна волшебная цепочка”, а несколько простых шагов, выстроенных в правильном порядке.

2. Порядок шагов пайплайна

Если начать писать отчёт «по вдохновению», легко перепутать, что делать раньше, а что позже. Поэтому полезно держать в голове стандартный порядок, который подходит для огромного числа задач анализа данных.

Сначала почти всегда идёт нормализация. Это приведение входных значений к «каноническому» виду: убрать пробелы, привести к нижнему регистру, заменить синонимы. Нормализация важна, потому что отчёт строится на совпадении ключей, а «food» и «FOOD» для компьютера — разные строки.

Потом обычно идёт фильтрация. Отбрасываем то, что не участвует в отчёте: например, отрицательные суммы (если они запрещены), или расходы ниже порога, или категории “test”.

После этого — агрегация: превращаем «много записей» в «итоги». Здесь появляются fold, groupBy, groupingBy().eachCount() и прочие штуки из предыдущих дней. groupBy() в Kotlin возвращает Map, где ключ — результат функции группировки, а значение — список элементов группы. А groupingBy() даёт способ посчитать результат по группам «без явных списков групп» (например, eachCount()).

Дальше — сортировка и выбор того, что мы реально показываем (top‑N через take(n)). Это уже ближе к «представлению», чем к «смыслу»: мы не меняем факты, мы решаем, как их показывать.

И только в конце — подготовка строк: превращаем итоговые данные (числа, пары, карты) в красивый текст.

Если вам кажется, что это звучит как «мы усложняем жизнь» — да, в момент написания кода чуть сложнее. Зато через неделю вы скажете себе «спасибо», когда надо будет добавить новый отчёт, не ломая старый.

3. Контроль типов в пайплайне

Теперь важный момент, который часто ломает мозг новичкам: каждый шаг пайплайна меняет тип результата. И это нормально.

Смена типов — не ошибка, а подсказка: «мы перешли на следующий уровень обработки». Если вы следите за типами, вы буквально видите архитектуру отчёта.

Представим, что в нашем учебном консольном приложении (условный трекер расходов) данные пока хранятся без классов, в виде:

// категория to сумма
val expenses: List<Pair<String, Int>>

Дальше типовой пайплайн может выглядеть так (в таблице намеренно «подсвечены» типы — это ваш компас):

Шаг Что делаем Тип входа Тип выхода
1 Нормализуем категории
List<Pair<String, Int>>
List<Pair<String, Int>>
2 Фильтруем (например, суммы > 0)
List<Pair<String, Int>>
List<Pair<String, Int>>
3 Агрегируем “сумма по категории”
List<Pair<String, Int>>
Map<String, Int>
4 Готовим строки для вывода (через сортировку)
Map<String, Int>
List<Pair<String, Int>>
или сразу
String
5 Рендерим текст отчёта
List<Pair<String, Int>>
String

Обратите внимание: где-то мы остаёмся в List, а где-то резко «переваливаемся» в Map. Этот переход — важная граница.

Особенно полезно помнить про groupBy(): после него вы получаете карту со списками Map<K, List<V>>. Это часто тот самый «переломный момент», когда стиль кода меняется: вместо «обрабатываем элементы» мы начинаем «обрабатывать группы».

Мини-данные для примеров

Чтобы примеры были связными, договоримся о небольшом наборе данных. Это не «финальная архитектура проекта», а просто удобный формат для дня: пара (категория, сумма).

fun main() {
    val expenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        "taxi" to 250,
        " books" to 90
    )

    println(expenses)
    // [( Food , 120), (FOOD, 80), (taxi, 250), ( books, 90)]
}

Да, категории здесь «грязные» (пробелы, разный регистр). Это специально: реальная жизнь не обязана быть красивой.

4. Нормализация как ранний шаг

Нормализация — это когда мы приводим данные к одному стилю, чтобы одинаковые сущности совпали. В отчётах это чаще всего относится к ключам: категориям, словам, именам, городам. Если вы нормализуете поздно (после группировки), вы получите раздробленные результаты: “food” отдельно, “FOOD” отдельно, “ Food ” отдельно. Отчёт будет выглядеть «правдоподобно», но неправильно — а это самый опасный вид неправильности.

Сделаем простую нормализацию категории: trim() + lowercase().

fun normalizeCategory(raw: String): String {
    return raw.trim().lowercase()
}

fun main() {
    val raw = listOf("  food ", "FOOD", " taxi")
    val normalized = raw.map(::normalizeCategory)

    println(normalized) // [food, food, taxi]
}

Тут важно, что мы применяем нормализацию через map, которая строит новую коллекцию из результатов преобразования. Мы ничего не «правим в исходнике», мы создаём новый, более чистый результат.

Теперь применим это к нашим расходам:

fun normalizeCategory(raw: String): String = raw.trim().lowercase()

fun main() {
    val expenses = listOf(" Food " to 120, "FOOD" to 80, "taxi" to 250)

    val normalizedExpenses = expenses.map { (cat, amount) ->
        normalizeCategory(cat) to amount
    }

    println(normalizedExpenses)
    // [(food, 120), (food, 80), (taxi, 250)]
}

Обратите внимание на тип: был List<Pair<String, Int>> и остался тем же типом. Это «ранний шаг», который не меняет форму данных, он делает их более пригодными к анализу.

5. Группировка и смена формы данных

После нормализации часто идёт группировка, потому что отчёт любит слова “по категориям”, “по пользователям”, “по дням недели”.

В Kotlin базовая группировка делается через groupBy(): она возвращает Map, где ключ — то, что вернула функция группировки, а значение — список элементов группы.

Сгруппируем расходы по категории:

fun normalizeCategory(raw: String): String = raw.trim().lowercase()

fun main() {
    val expenses = listOf(" Food " to 120, "FOOD" to 80, "taxi" to 250)

    val normalized = expenses.map { (cat, amount) -> normalizeCategory(cat) to amount }
    val grouped = normalized.groupBy { it.first }

    println(grouped)
    // {food=[(food, 120), (food, 80)], taxi=[(taxi, 250)]}
}

Вот тут и произошёл «переломный момент». Тип был List<Pair<String, Int>>, а стал:

Map<String, List<Pair<String, Int>>>

То есть карта списков. И это логично: в каждой категории может быть много записей.

Если не нужны списки групп

Иногда отчёту не нужны элементы групп, ему нужна только статистика. Например: «сколько раз встречалась категория». Для этого часто удобнее groupingBy().eachCount(), которая делает частоты.

Мини‑пример со словами:

fun main() {
    val words = listOf("a", "bb", "ccc", "bb")
    val counts: Map<String, Int> = words.groupingBy { it }.eachCount()

    println(counts) // {a=1, bb=2, ccc=1}
}

Смысл: мы сразу получаем Map<String, Int>, то есть «итоговую карту», без промежуточных списков. Это особенно приятно, когда данных много.

Для расходов eachCount() даст «сколько операций в каждой категории», а не суммы. Иногда это полезно как отдельный отчёт (“количество покупок по категориям”), но чаще нам всё же нужны суммы — этим займёмся в следующих шагах пайплайна.

6. Агрегация и fold

Агрегация — это момент, когда мы говорим: «хватит деталей, дай итог». Самая простая агрегация — сумма.

В Kotlin один из ключевых инструментов для этого — fold(). Он аккуратно проходит по коллекции и накапливает результат. В отличие от reduce(), fold() принимает стартовое значение аккумулятора, поэтому он безопаснее для пустых коллекций и лучше подходит новичкам.

Посчитаем общую сумму расходов (это отчёт total, но сегодня нам важен именно шаг агрегации):

fun main() {
    val amounts = listOf(10, 20, 30)
    val total = amounts.fold(0) { acc, x -> acc + x }

    println(total) // 60
}

Если вы когда-нибудь писали это через var sum = 0 и цикл — вы уже знаете fold, просто раньше делали его вручную. Kotlin лишь делает этот паттерн явным и компактным.

Теперь агрегация “сумма по категориям”. Мы уже получили Map<String, List<Pair<String, Int>>>. Теперь внутри каждой группы надо свернуть список в число.

fun main() {
    val grouped = mapOf(
        "food" to listOf("food" to 120, "food" to 80),
        "taxi" to listOf("taxi" to 250)
    )

    val totalsByCategory: Map<String, Int> = grouped.mapValues { (_, items) ->
        items.fold(0) { acc, item -> acc + item.second }
    }

    println(totalsByCategory) // {food=200, taxi=250}
}

Здесь важный момент про контроль типов. После mapValues мы уже не имеем «списки операций», у нас «итоговые числа». Пайплайн перешёл из «деталей» в «результат».

И да, mapValues — это трансформация карты (ключи те же, значения новые). Это ровно тот стиль «преобразуем, а не мутируем», к которому Kotlin подталкивает через операции коллекций.

7. Сортировка и подготовка вывода

После агрегации хочется показать результат красиво: сверху самые большие категории, ниже поменьше. Это уже не «изменение смысла», это подготовка представления.

Типичная проблема новичка: «Как отсортировать Map?». Ответ простой: чаще всего вы не сортируете карту “как есть”, вы превращаете её в список элементов (например, пар), сортируете список и уже его печатаете.

Пример: Map<String, Int>List<Pair<String, Int>> → сортировка по сумме по убыванию.

fun main() {
    val totals = mapOf("taxi" to 250, "food" to 200, "books" to 90)

    val sorted = totals.entries
        .map { it.key to it.value }
        .sortedByDescending { (_, sum) -> sum }

    println(sorted)
    // [(taxi, 250), (food, 200), (books, 90)]
}

Снова следим за типами:

  • totals — это Map<String, Int>
  • totals.entries — это набор элементов карты (по сути, «список записей карты»)
  • .map { ... } превращает это в List<Pair<String, Int>>
  • .sortedByDescending возвращает новый отсортированный список (и исходные данные не трогает)

Дальше вы можете сделать top‑N через take(n) — но сам принцип уже понятен: сортировка делается ближе к выводу.

8. Рендер отчёта в строку

Когда очень хочется побыстрее увидеть результат, рука тянется писать println() прямо внутри агрегации. Это даёт мгновенное удовлетворение, но портит архитектуру: вычисление смешивается с выводом. Потом вы захотите сохранить отчёт в строку, вывести в другом формате или протестировать — и окажется, что у вас «всё печатает само себя».

Поэтому правило дня: сначала считаем данные, потом строим текст.

Для построения текста удобно использовать StringBuilder, потому что он предназначен для сборки строк без тяжёлой конкатенации в цикле. В Kotlin есть удобные расширения для него (например, .appendLine()).

Сделаем простой рендер списка пар (категория, сумма):

fun renderTotals(rows: List<Pair<String, Int>>): String {
    val sb = StringBuilder()
    for ((category, total) in rows) {
        sb.append(category)
        sb.append(": ")
        sb.append(total)
        sb.append('\n')
    }
    return sb.toString()
}

fun main() {
    val rows = listOf("taxi" to 250, "food" to 200)
    print(renderTotals(rows))
    // taxi: 250
    // food: 200
}

Обратите внимание: функция ничего не печатает. Она возвращает строку. А печать — это уже дело main или слоя CLI.

Это мелочь, но именно из таких мелочей собирается «код, который можно поддерживать».

9. Склеиваем пайплайн целиком

Теперь давайте соберём небольшой цельный пример пайплайна, который делает почти полный отчёт: нормализация → агрегация → сортировка → текст.

Да, мы могли бы сделать это одной цепочкой на 8 вызовов. Но сегодня важна читаемая архитектура: шаги идут по порядку, и мы глазами видим, где какой тип.

fun normalizeCategory(raw: String): String = raw.trim().lowercase()

fun main() {
    val expenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        "taxi" to 250,
        " books" to 90
    )

    val normalized = expenses.map { (cat, amount) ->
        normalizeCategory(cat) to amount
    }

    val grouped = normalized.groupBy { it.first }

    val totalsByCategory = grouped.mapValues { (_, items) ->
        items.fold(0) { acc, item -> acc + item.second }
    }

    val sortedRows = totalsByCategory.entries
        .map { it.key to it.value }
        .sortedByDescending { (_, sum) -> sum }

    val report = renderTotals(sortedRows)

    print(report)
    // taxi: 250
    // food: 200
    // books: 90
}

Что здесь важно (именно с точки зрения «архитектуры отчёта»):

  • Мы не делаем вывод в середине, потому что середина — это ещё «данные», и их можно переиспользовать для других отчётов. Это особенно полезно в приложениях, где вы захотите показывать разные отчёты по одной и той же базе операций.
  • Мы явно видим смену типа на groupBy(), потому что groupBy() возвращает Map. И дальше явно видим, что после fold внутри mapValues у нас получаются именно числа (итоги).
  • Мы сортируем перед выводом, чтобы порядок строк был предсказуемым. (Если полагаться на «как карта напечатает», вы можете однажды удивиться. Удивление — не лучший формат для финансовых отчётов.)

10. Типичные ошибки при проектировании отчёта‑пайплайна

Ошибка №1: смешивать нормализацию и форматирование.
Когда вы делаете trim().lowercase() и тут же превращаете результат в красивую строку “Категория: …”, вы связываете обработку данных и оформление в один узел. Потом оказывается, что вам нужно ту же нормализацию использовать для группировки, но без “Категория:”. Гораздо проще: сначала нормализовать «как данные», и только в конце — форматировать «как текст».

Ошибка №2: не замечать смену формы результата.
Новички часто продолжают думать «у меня всё ещё список», хотя после groupBy() у них уже Map<String, List<...>>. Отсюда рождаются странные попытки вызвать sortedBy «на карте» или написать лямбду, которая ожидает элемент списка, а получает Map.Entry. Если держать в голове тип после каждого шага, половина путаницы исчезает сама.

Ошибка №3: группировать по грязному ключу.
Если вы делаете groupBy { it.first } до trim/lowercase, вы получаете «правильную» карту… из неправильных ключей. Отчёт начинает дробиться, суммы расползаются, а пользователь потом спрашивает: “Почему у меня две категории food?”. Потому что одна “food”, а другая “ Food ”. Нормализация должна быть до группировки почти всегда.

Ошибка №4: пытаться сортировать Map “как есть”, а потом удивляться.
Карта — это не список. В отчётах сортировка чаще всего делается по списку элементов: берём entries, превращаем в пары/строки, сортируем, потом печатаем. Это не «обходной путь», это стандартный паттерн подготовки представления.

Ошибка №5: делать reduce, не подумав про пустые данные.
reduce не имеет стартового значения и может упасть на пустой коллекции. fold безопаснее, потому что у него есть начальный аккумулятор. В отчётах пустые данные — нормальная ситуация (например, ещё ничего не добавили), поэтому fold обычно комфортнее.

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