JavaRush /Курси /Kotlin SELF /Набір звітів v0: total, byCategory, top, threshold в єдин...

Набір звітів v0: total, byCategory, top, threshold в єдиному стилі

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

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>, де ключ — категорія, а значення — сума за цією категорією.

Зробімо це у два зрозумілих кроки: groupBymapValues + 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.

Функція Приймає Повертає Зміст
normalizeExpenses(...)
List<Pair<String, Int>>
List<Pair<String, Int>>
Приводимо категорії до єдиного вигляду
calcTotal(...)
List<Pair<String, Int>>
Int
Сума всіх витрат
totalsByCategory(...)
List<Pair<String, Int>>
Map<String, Int>
Суми за категоріями
applyThreshold(...)
Map<String, Int>
,
threshold
List<Pair<String, Int>>
Відбираємо категорії за порогом
topCategories(...)
Map<String, Int>
,
n
,
threshold
List<Pair<String, Int>>
Топ категорій за сумою
render...(...)
числа/структури
String
Готуємо текст звіту

Ця табличка — не «документація заради документації», а спосіб не загубитися, коли звітів стане більше.

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 зі стартовим значенням. Це робить поведінку не лише безпечною, а й логічною: сума порожнього набору — нуль, і ніхто не плаче.

1
Опитування
Звіти та пайплайни перетворень, рівень 26, лекція 4
Недоступний
Звіти та пайплайни перетворень
Звіти та пайплайни перетворень
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ