1. Домовимося про дані: як звіти отримують витрати
Коли кажуть «зробіть звіт», початківець часто уявляє собі величезний println, який якимось дивом усе порахує й гарно «намалює». На практиці звіт живе довше, ніж натхнення. До нього повертаються, його доповнюють, його порівнюють з іншими звітами. Тому перше «доросле» рішення — домовитися про формат вхідних даних і тримати його стабільним хоча б на цьому етапі курсу (без класів і ООП).
У нашому практичному консольному застосунку для обліку витрат (умовному «мінітрекері») вважатимемо, що одна витрата — це пара:
- first: категорія (String)
- second: сума (Int) в умовних «одиницях»; головне — ціле число
Тобто весь список витрат — це List<Pair<String, Int>>.
Ось невеликий приклад даних, з якими працюватимемо далі:
fun main() {
val expenses: List<Pair<String, Int>> = listOf(
" food " to 120,
"FOOD" to 80,
"taxi" to 250,
"Books" to 90
)
println(expenses)
// [( food , 120), (FOOD, 80), (taxi, 250), (Books, 90)]
}
Так, категорії тут «брудні» (пробіли, різний регістр). І це не помилка — так буває в реальному житті. Користувач завжди знайде спосіб увести дані так, щоб ваша програма почувалася невпевнено. Тож будемо розумнішими.
2. Спільний крок: нормалізація категорії
Якщо ви хоча б раз групували рядки «як є», то бачили цю магію в лапках: "Food" і " food " раптом стають різними категоріями. А звіт починає виглядати так, ніби людина харчується у трьох паралельних всесвітах. Щоб цього не ставалося, нормалізація — обовʼязковий спільний крок для всіх звітів. І так: нормалізація — це не «гарний вивід», а приведення даних до єдиного внутрішнього вигляду ще до обчислень.
Зробімо невеличку функцію нормалізації категорії: trim() + lowercase(). Вона має бути передбачуваною, короткою й однаковою всюди.
fun normalizeCategory(raw: String): String {
return raw.trim().lowercase()
}
fun main() {
println(normalizeCategory(" FOOD ")) // food
println(normalizeCategory("Books")) // books
}
Тепер застосуймо нормалізацію до всіх витрат. Зверніть увагу: ми не змінюємо вихідний список «на місці», а будуємо новий. Це допомагає тримати обчислення чистими та зменшує кількість сюрпризів.
fun normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> {
return expenses.map { (cat, amount) ->
normalizeCategory(cat) to amount
}
}
fun main() {
val raw = listOf(" FOOD " to 10, "food" to 5)
val normalized = normalizeExpenses(raw)
println(normalized) // [(food, 10), (food, 5)]
}
Майже всі наші звіти далі починатимуться однаково: «беремо витрати → нормалізуємо категорії → рахуємо». Це і є «єдиний стиль».
3. Каркас звітів: обчислення окремо, виведення окремо
Дуже хочеться «зробити красиво» одразу: у процесі підрахунку друкувати рядки, додавати емодзі (але ми в Kotlin‑курсі, а не в Telegram‑стікерах), вирівнювати все пробілами… і зрештою змішати обчислення з виведенням так, що змінити формат стане болючіше, ніж піднятися в понеділок.
Тому фіксуємо правило дня:
- функції обчислення повертають числа або структури (Int, Map, List<Pair<...>>)
- функції відображення (рендерингу) перетворюють результат на String (або друкують, але краще — повертають рядок)
- main (або командний обробник) вирішує, що і коли показувати
Схематично це виглядає так:
flowchart LR
A[raw expenses] --> B[normalize]
B --> C[calculate totals]
C --> D[optional: filter threshold]
D --> E[optional: sort + top-N]
E --> F[render string]
У цій лекції ми реалізуємо 4 звіти (total, byCategory, top, threshold) так, щоб вони відчувалися як родичі, а не як випадкові перехожі.
4. Звіт total: сума всіх витрат
Звіт total здається найпростішим: «складіть усі суми». Але саме в таких місцях і зʼявляються типові помилки початківців: зовнішній лічильник, забули обнулити, порожній список «ламає логіку», десь посередині раптом зʼявляється println тощо. Гарна новина: у Kotlin є fold, який якраз призначений для акуратного накопичення результату.
Зробімо функцію обчислення загальної суми. Ми підсумовуємо second у кожній парі.
fun calcTotal(expenses: List<Pair<String, Int>>): Int {
return expenses.fold(0) { acc, item ->
acc + item.second
}
}
fun main() {
val expenses = listOf("food" to 10, "taxi" to 20)
println(calcTotal(expenses)) // 30
}
Чому fold(0), а не reduce? Тому що fold уміє працювати навіть із порожнім списком. Вона стартує з початкового значення, і результат буде передбачуваним.
Тепер зробімо рендер для total. Нехай це буде простий рядок:
fun renderTotal(total: Int): String {
return "Разом: $total"
}
fun main() {
println(renderTotal(60)) // Разом: 60
}
І одразу маленький «складальний» приклад: нормалізація + total.
fun main() {
val raw = listOf(" FOOD " to 120, "taxi" to 250)
val expenses = normalizeExpenses(raw)
val total = calcTotal(expenses)
println(renderTotal(total)) // Разом: 370
}
5. Звіт byCategory: сума за категоріями
Звіт «за категоріями» — це перший момент, коли дані змінюють форму: ми переходимо від списку окремих операцій до агрегованих підсумків. І це нормально: звіт якраз для того й існує, щоб перетворювати «сирі події» на «зрозумілі підсумки». Головне — не лякатися зміни типів і робити це усвідомлено.
Наша мета: отримати Map<String, Int>, де ключ — категорія, а значення — сума за цією категорією.
Зробімо це у два зрозумілих кроки: groupBy → mapValues + fold.
fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> {
val grouped: Map<String, List<Pair<String, Int>>> =
expenses.groupBy { it.first }
return grouped.mapValues { (_, items) ->
items.fold(0) { acc, item -> acc + item.second }
}
}
fun main() {
val expenses = listOf("food" to 10, "food" to 5, "taxi" to 7)
println(totalsByCategory(expenses)) // {food=15, taxi=7}
}
Тут корисно на секунду зупинитися й проговорити: groupBy створює списки всередині мапи. Це зручно для розуміння, але інколи може бути важкувато для памʼяті. У попередніх лекціях ви бачили groupingBy { ... }.eachCount() і загалом ідею, що можна групувати «без явних списків». Сьогодні ми не женемося за оптимізацією — ми женемося за читабельністю.
Тепер — відображення. Важливий нюанс: у Map порядок друку не варто вважати «контрактом звіту». Якщо вам потрібне детерміноване виведення, сортуйте самі. Це напряму повʼязано з темою впорядкування колекцій: сортування за ключем або значенням має бути явним. Інакше порядок стає «як пощастить».
Зробімо відображення «табличкою». Для простоти відсортуємо за назвою категорії.
fun renderByCategory(totals: Map<String, Int>): String {
val rows: List<Pair<String, Int>> = totals.entries
.map { it.key to it.value }
.sortedBy { (cat, _) -> cat }
val sb = StringBuilder()
for ((cat, sum) in rows) {
sb.append(cat).append(": ").append(sum).append('\n')
}
return sb.toString()
}
fun main() {
val totals = mapOf("taxi" to 250, "food" to 200)
print(renderByCategory(totals))
// food: 200
// taxi: 250
}
Зверніть увагу: ми повертаємо рядок. А друкувати його чи записувати у файл (колись потім) — уже рішення коду, який викликає цю функцію.
6. threshold: поріг як окремий крок
Поріг (threshold) часто зʼявляється у звітах як «покажіть лише значущі рядки». Наприклад: «покажіть категорії, де сума >= 100». Пастка тут проста: поріг легко застосувати не в той момент — і отримати дивні результати. Щоб не сперечатися з майбутнім собою, робимо поріг окремим, «чистим» кроком, який працює з уже агрегованими даними.
Тобто threshold — це фільтрація підсумків, а не фільтрація окремих операцій. Якщо фільтрувати операції, логіка стане іншою: ви почнете відкидати дрібні покупки, хоча категорія сумарно могла бути великою.
Зробімо функцію, яка приймає Map<String, Int> і повертає список пар, зручний для подальшого сортування та відображення:
fun applyThreshold(
totals: Map<String, Int>,
threshold: Int
): List<Pair<String, Int>> {
return totals.entries
.map { it.key to it.value }
.filter { (_, sum) -> sum >= threshold }
}
fun main() {
val totals = mapOf("taxi" to 250, "food" to 90, "books" to 110)
println(applyThreshold(totals, threshold = 100))
// [(taxi, 250), (books, 110)]
}
Зараз це виглядає як «ну так, фільтр». Але сенс — в архітектурі: цей крок можна однаково вставляти в різні звіти, і код читатиметься як конструктор: «порахували → відфільтрували → відсортували → вивели».
7. Звіт top: топ‑N категорій за сумою
Top‑N — типовий «представницький» крок: ми вже все порахували, а тепер вирішуємо, що показати першим. Сортування і take(n) логічно робити ближче до виведення: вони не змінюють зміст даних, а лише те, як ми їх показуємо користувачеві.
Зробімо функцію topCategories, яка приймає вже агреговані підсумки. А поріг підʼєднаємо як опцію: спочатку фільтр за порогом, потім сортування за спаданням, а тоді take(n).
fun topCategories(
totals: Map<String, Int>,
n: Int,
threshold: Int
): List<Pair<String, Int>> {
return totals.entries
.map { it.key to it.value }
.filter { (_, sum) -> sum >= threshold }
.sortedByDescending { (_, sum) -> sum }
.take(n)
}
fun main() {
val totals = mapOf("taxi" to 250, "food" to 200, "books" to 90)
println(topCategories(totals, n = 2, threshold = 100))
// [(taxi, 250), (food, 200)]
}
Тут порядок особливо важливий. Якщо ви спочатку зробите take(n), а потім застосуєте поріг, то можете «втратити» рядки. Ви взяли перші N до фільтрації, потім частину відкинули — і звіт раптом показав менше рядків, ніж міг би. Тому фільтрація за порогом має бути перед take(n).
Тепер — відображення топу. Воно майже таке саме, як відображення byCategory, але порядок уже задано (ми сортували за сумою). Тож функція просто виводить рядки в поточному порядку:
fun renderTop(items: List<Pair<String, Int>>): String {
val sb = StringBuilder()
for ((cat, sum) in items) {
sb.append(cat).append(": ").append(sum).append('\n')
}
return sb.toString()
}
fun main() {
val top = listOf("taxi" to 250, "food" to 200)
print(renderTop(top))
// taxi: 250
// food: 200
}
8. Збираємо звіти v0 в єдиний стиль
Коли функцій стає більше двох, мозок починає цінувати ритуали. Гарний стиль — це не «красиво», а «передбачувано». Ви відкриваєте файл за тиждень і одразу розумієте, де що шукати. Тож зберімо все в невеликий приклад, схожий на те, як житиме код у нашому застосунку (нехай поки що прямо в одному файлі).
Зверніть увагу на назви проміжних val: вони описують кроки конвеєра.
fun main() {
val rawExpenses = listOf(
" food " to 120,
"FOOD" to 80,
"taxi" to 250,
"Books" to 90,
"books " to 30
)
val expenses = normalizeExpenses(rawExpenses)
val total = calcTotal(expenses)
println(renderTotal(total)) // Разом: 570
val totalsByCat = totalsByCategory(expenses)
print(renderByCategory(totalsByCat))
// books: 120
// food: 200
// taxi: 250
val top = topCategories(totalsByCat, n = 2, threshold = 100)
print(renderTop(top))
// taxi: 250
// food: 200
}
Це вже виглядає як набір звітів, а не як один «божественний println». Так, поки все просте. Але саме такий стиль витримує ускладнення: ви додаєте звіт — і він лягає в ту саму схему.
Контракти звітів: що приймаємо і що повертаємо
Коли ви пишете багато функцій, корисно тримати контракти на видноті. Це зменшує шанс «впхнути невпихуване» і почати передавати Map туди, де очікують List. Нижче — компактна таблиця наших функцій v0.
| Функція | Приймає | Повертає | Зміст |
|---|---|---|---|
|
|
|
Приводимо категорії до єдиного вигляду |
|
|
|
Сума всіх витрат |
|
|
|
Суми за категоріями |
|
, |
|
Відбираємо категорії за порогом |
|
, , |
|
Топ категорій за сумою |
|
числа/структури | |
Готуємо текст звіту |
Ця табличка — не «документація заради документації», а спосіб не загубитися, коли звітів стане більше.
9. Типові помилки
Помилка № 1: рахувати top за «сирими» операціями, а не за агрегованими підсумками.
Якщо взяти вихідний список витрат і просто відсортувати його за сумою, ви отримаєте «найбільші окремі витрати», а не «найдорожчі категорії». Іноді це навіть корисний звіт, але це інший звіт. Щоб top був саме за категоріями, спочатку робіть totalsByCategory, а вже потім сортуйте підсумки.
Помилка № 2: застосовувати threshold після take(n) і дивуватися, чому рядків менше, ніж очікували.
Логіка «спочатку взяти топ‑2, потім відфільтрувати за порогом» призводить до того, що ви можете відкинути один рядок і залишитися з топ‑1. Хоча в даних були кандидати, які пройшли б поріг і могли зайняти це місце. Поріг — це фільтр кандидатів, а take(n) — фінальний «обрізувач». Тому поріг ставлять раніше.
Помилка № 3: друкувати всередині функцій обчислення.
Щойно ви робите println усередині totalsByCategory або calcTotal, ви ламаєте повторне використання. Сьогодні ви друкуєте в консоль, завтра захочете зібрати один великий рядок, післязавтра — зберегти у файл. Якщо обчислення повертає значення, а виведення робиться окремо, ви не заганяєте себе в кут.
Помилка № 4: очікувати, що Map «сам по собі» друкується в потрібному порядку.
Навіть якщо «у вас зараз так збіглося», це не контракт. Звіт має бути детермінованим: або сортуйте за ключем, або за значенням, або явно документуйте порядок. У наших прикладах ми перед виведенням перетворюємо entries на список пар і сортуємо — це робить результат стабільним і читабельним.
Помилка № 5: забути нормалізацію категорії й отримати «FOOD», «food» і « food » як три різні світи.
Це класика: ви групуєте за рядком, а рядок «брудний». У звітах це особливо боляче, бо людина дивиться на підсумок і робить висновки. Тому нормалізація — спільний крок для всіх звітів, і краще тримати її в одній функції, щоб не розʼїхалися правила.
Помилка № 6: використовувати reduce там, де список може бути порожнім, і зловити виняток.
reduce вимагає хоча б один елемент, інакше їй «нема з чого починати». Для звітів порожній список — нормальна ситуація (користувач ще нічого не додав), тому для сум і накопичень обирайте fold зі стартовим значенням. Це робить поведінку не лише безпечною, а й логічною: сума порожнього набору — нуль, і ніхто не плаче.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ