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 | Нормалізуємо категорії | |
|
| 2 | Фільтруємо (наприклад, суми > 0) | |
|
| 3 | Агрегуємо “сума за категорією” | |
|
| 4 | Готуємо рядки для виведення (через сортування) | |
або одразу |
| 5 | Рендеримо текст звіту | |
|
Зверніть увагу: десь ми лишаємося в 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 зазвичай комфортніший.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ