JavaRush /Курси /Kotlin SELF /Архітектура звіту як пайплайна

Архітектура звіту як пайплайна

Kotlin SELF
Рівень 26 , Лекція 0
Відкрита

1. Звіт як пайплайн

Коли ви чуєте слово «звіт», уява новачка часто малює щось на кшталт: «взяли список → пробіглися → надрукували». Це робоча схема… рівно до першої вимоги на кшталт «зроби ще топ‑3 категорії», «а ще відфільтруй лише суми ≥ X», «а ще зроби так, щоб “Food”, “ food ” і “FOOD” були однією категорією». У цей момент код починає нагадувати клубок дротів під столом: наче працює, але чіпати страшно.

Тому нам потрібен інший погляд: звіт — це пайплайн.

Пайплайн — це коли ми не робимо «все одразу», а будуємо ланцюжок кроків:

вхідні дані → перетворення → підсумкові дані → текст звіту

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

Зафіксуймо головну думку дня:

Звіт — це не «один чарівний ланцюжок», а кілька простих кроків, вибудуваних у правильному порядку.

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

Якщо почати писати звіт «за натхненням», легко переплутати, що робити раніше, а що пізніше. Тому корисно тримати в голові стандартний порядок. Він підходить для великої кількості завдань з аналізу даних.

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

Потім зазвичай іде фільтрація. Ми відкидаємо те, що не бере участі у звіті: наприклад, відʼємні суми (якщо вони заборонені), витрати нижче порога або категорії “test”.

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

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

І лише наприкінці — підготовка рядків: перетворюємо підсумкові дані (числа, пари, мапи) на гарний текст.

Якщо вам здається, що це звучить як «ми ускладнюємо життя», — частково так. Під час написання коду спершу стає трохи складніше. Зате за тиждень ви скажете собі «дякую», коли потрібно буде додати новий звіт, не ламаючи старий.

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

Тепер важливий момент, який часто «ламає мозок» новачкам: кожен крок пайплайна змінює тип результату. І це нормально.

Зміна типів — не помилка, а підказка: «ми перейшли на наступний рівень обробки». Якщо ви стежите за типами, то буквально бачите архітектуру звіту.

Уявімо, що в нашому навчальному консольному застосунку (умовному трекері витрат) дані поки що зберігаються без класів, у вигляді:

// категорія → сума
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 повертає новий відсортований список (і вихідні дані не чіпає)

Далі ви можете зробити топ‑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. Склеюємо пайплайн повністю

Тепер давайте зберемо невеликий цілісний приклад пайплайна, який робить майже повний звіт: нормалізація → агрегація → сортування → текст.

Так, ми могли б зробити це одним ланцюжком із восьми викликів. Але сьогодні важлива читабельна архітектура: кроки йдуть у правильному порядку, і ми очима бачимо, де який тип.

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 зазвичай комфортніший.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ