JavaRush /Курси /Kotlin SELF /Агрегації через fold: суми, мінімуми та збирання структур...

Агрегації через fold: суми, мінімуми та збирання структур

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

1. Агрегація та fold як загальний прийом

Коли ви тільки починаєте програмувати, може здаватися, що майже всі завдання розвʼязуються однаково: «взяв цикл, зробив var result — і в циклі накопичую». Часто так і є… доки таких циклів не стає забагато й ви не починаєте плутатися: де саме й що накопичуєте та чому воно раптом «випадково» перевикористалося.

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

fold як універсальний «конструктор результату»

Якщо коротко, fold — це «перебирання колекції, де ви самі задаєте, яким буде підсумковий тип результату». Причому підсумковий тип (R) узагалі не зобовʼязаний збігатися з типом елементів (T). Це ключова ідея: ми не просто «підсумовуємо числа», ми «збираємо результат потрібної форми».

З погляду контракту fold приймає стартове значення та функцію-комбайнер. Вона щоразу отримує старий акумулятор і новий елемент, а повертає новий акумулятор. У документації це буквально читається так: «беремо накопичене та елемент → отримуємо нове накопичене».

Подивімося на схему, щоб мозок не намагався уявити це як «чорну скриньку»:

flowchart TD
    A["initial (acc)"] --> B["combine(acc, x1) -> acc1"]
    B --> C["combine(acc1, x2) -> acc2"]
    C --> D["combine(acc2, x3) -> acc3"]
    D --> E["... підсумковий accN"]

Якщо ви звикли до циклів, можна мислити так: fold — це «культурний цикл», де замість зовнішнього var ви зобовʼязані повертати нове значення акумулятора.

Нейтральний елемент і вибір initial

У fold(initial) { acc, x -> ... } параметр initial — не «будь-яке число для старту», а частина сенсу вашої операції. Його часто називають нейтральним елементом: таким стартом, який не спотворює результат.

Якщо ви рахуєте суму, нейтральний елемент — 0, бо 0 + x = x. Якщо ви рахуєте добуток — 1, бо 1 * x = x. Якщо ви збираєте рядок — нейтральний елемент найчастіше порожній рядок "" або StringBuilder() (залежно від підходу). Якщо ви збираєте Map, нейтральний елемент — порожня мапа.

Нижче — невелика табличка «часті агрегати → вдалий initial»:

Що збираємо Тип результату initial Чому так
Сума
Int / Long / Double
0 / 0L / 0.0
Нуль не впливає на суму
Добуток
Int / Long / Double
1 / 1L / 1.0
Одиниця не впливає на добуток
Конкатенація тексту
String
""
Порожній рядок не додає символів
Великий текст
StringBuilder
StringBuilder()
Ми «добудовуємо» обʼєкт
Накопичення за ключем
MutableMap<K, V>
mutableMapOf()
Починаємо з порожньої структури

Найнеприємніше тут те, що неправильний initial часто не спричиняє помилки компіляції. Програма «працює», просто видає нісенітницю. Це такий баг, який не падає, а тихо сміється з вас.

2. Базові агрегації на прикладі витрат

Щоб приклади не були «сферичними числами у вакуумі», продовжимо наш практичний консольний проєкт обліку витрат. Поки що без ООП: витрата — це Pair<String, Int>, де first — категорія, а second — сума в умовних одиницях.

Почнемо з простого набору даних:

fun main() {
    val expenses: List<Pair<String, Int>> = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    println(expenses)
    // [(food, 120), (taxi, 250), (food, 80), (books, 540)]
}

Підсумкова сума через fold

Тепер порахуємо загальну суму всіх витрат. Це класичне «багато чисел → одне число»:

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val total = expenses.fold(0) { acc, (_, amount) ->
        acc + amount
    }

    println("Усього: $total") // Усього: 990
}

Зверніть увагу на маленьку деталь: ми деконструюємо пару як (_, amount). Категорія в сумі не бере участі, тому _ — чесний сигнал «я це ігнорую».

Якщо вас раптом наздогнала думка «а чому не sumOf?» — вона цілком здорова. Але сьогодні ми свідомо тренуємо саме підхід fold: він знадобиться, коли готової функції «зроби мені гарно» просто немає.

Підрахунок за умовою через fold

У Kotlin є count { ... }, і в реальному житті ви, найімовірніше, користуватиметеся саме ним. Але щоб добре зрозуміти fold, корисно один раз «зібрати» count власноруч. Так ви побачите, що агрегація не зобовʼязана бути сумою грошей: це може бути й «сума фактів».

Наприклад: скільки витрат більші за 200?

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val bigCount = expenses.fold(0) { acc, (_, amount) ->
        if (amount > 200) acc + 1 else acc
    }

    println("Великих витрат: $bigCount") // Великих витрат: 2
}

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

3. Мінімум і максимум як агрегації

Мінімум/максимум — класичний тип завдань, де новачки часто йдуть двома шляхами: або сортують список (це дорого й зайве), або пишуть цикл із var min = ... і впираються в питання: «а що робити, якщо список порожній?».

Сьогодні подивимося на практичний стиль fold для мінімуму, який одразу враховує порожній випадок. Ідея проста: робимо акумулятор nullable.

Наприклад, знайдемо мінімальну суму витрати:

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val minAmount: Int? = expenses.fold(null as Int?) { acc, (_, amount) ->
        if (acc == null || amount < acc) amount else acc
    }

    println("Мінімальна сума: $minAmount") // Мінімальна сума: 80
}

Перевага такого підходу в тому, що він коректний і для порожнього списку:

fun main() {
    val empty = emptyList<Pair<String, Int>>()

    val minAmount: Int? = empty.fold(null as Int?) { acc, (_, amount) ->
        if (acc == null || amount < acc) amount else acc
    }

    println("Мінімальна сума: $minAmount") // Мінімальна сума: null
}

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

4. Збирання тексту через fold і StringBuilder

У консольних програмах рано чи пізно зʼявляється бажання зробити виведення «як у дорослому застосунку»: кілька рядків, вирівнювання, охайний підсумок. І тут легко потрапити в пастку нескінченної конкатенації рядків через +, яка перетворює код на макарони.

fold чудово підходить для збирання звіту, особливо якщо акумулятором зробити StringBuilder. Це саме той випадок, коли підсумок — не число, а текст.

Зберемо найпростіший звіт: кожна покупка — окремий рядок.

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

    val report = expenses.fold(StringBuilder()) { acc, (category, amount) ->
        acc.append(category)
            .append(": ")
            .append(amount)
            .append('\n')
    }.toString()

    print(report)
    // food: 120
    // taxi: 250
    // food: 80
}

Зверніть увагу на «трюк»: лямбда повертає StringBuilder. Метод append якраз повертає той самий обʼєкт, тому acc.append(...).append(...) залишається тим самим акумулятором — ми просто його «нарощуємо».

Тепер додамо до звіту підсумковий рядок. Тут добре виглядає двокроковий підхід: окремо рахуємо суму, окремо формуємо текст. Так код читається простіше.

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

    val total = expenses.fold(0) { acc, (_, amount) -> acc + amount }

    val report = expenses.fold(StringBuilder()) { acc, (category, amount) ->
        acc.append("- ")
            .append(category)
            .append(": ")
            .append(amount)
            .append('\n')
    }.append("Усього: ").append(total).append('\n')
     .toString()

    print(report)
    // - food: 120
    // - taxi: 250
    // - food: 80
    // Усього: 450
}

Так, можна було б зробити все одним fold, але тоді всередині лямбди зʼявляються «побічні обчислення», які важче супроводжувати. Коли ви тільки вчитеся, краще тримати обчислення й форматування окремо: мозок теж любить чисту архітектуру.

5. Збирання структур і комбінування агрегацій

Накопичення сум за категоріями через Map

Тепер найцікавіше: fold уміє збирати не лише «одне число» або «один текст», а й цілу структуру. Для обліку витрат це справжній хіт: «категорія → сума за категорією».

Ми хочемо отримати Map<String, Int>:

  • "food" -> 200
  • "taxi" -> 250
  • "books" -> 540

Зробимо це через fold, де акумулятор — MutableMap<String, Int>.

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val totalsByCategory: Map<String, Int> =
        expenses.fold(mutableMapOf<String, Int>()) { acc, (category, amount) ->
            acc[category] = (acc[category] ?: 0) + amount
            acc
        }

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

Тут важливо зрозуміти, чому наприкінці ми повертаємо acc. Лямбда fold зобовʼязана повернути «наступне значення акумулятора». Ми змінили acc (бо це mutable-обʼєкт), але fold усе одно очікує повернене значення, щоб передати його на наступний крок. Тому шаблон виглядає так: «оновили acc → повернули acc».

Цей прийом спочатку здається дивним: «я ж уже змінив мапу, навіщо повертати її?». Відповідь проста: тому що fold працює однаково для всіх типів акумуляторів — і для immutable, і для mutable. У його контракті завжди є вимога: «поверни новий акумулятор».

Від структури до підсумку: «найдорожча категорія»

Уявімо, що в нас уже є мапа «категорія → сума»:

val totalsByCategory = mapOf(
    "food" to 200,
    "taxi" to 250,
    "books" to 540
)

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

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

    val best: Pair<String, Int>? =
        totalsByCategory.entries.fold(null as Pair<String, Int>?) { acc, e ->
            val current = e.key to e.value
            if (acc == null || current.second > acc.second) current else acc
        }

    println(best) // (books, 540)
}

Тут ми агрегуємо entries, бо нам потрібні і ключ, і значення. В акумулятор кладемо Pair<String, Int>, тобто «найкращу знайдену пару».

Рефакторинг: функції-агрегатори замість «магії в main»

Коли агрегацій стає більше, ніж одна-дві, main починає перетворюватися на кухню: одночасно «смажиться» код, кипить логіка, а хтось іще намагається друкувати звіт. Винесімо агрегатори у функції. Це робить програму читабельнішою й формує корисну звичку: «код живе у функціях, а не в main».

Зробимо три функції:

  • totalAmount(expenses) — сума всіх витрат
  • minAmountOrNull(expenses) — мінімум за сумою
  • totalsByCategory(expenses) — мапа сум за категоріями
fun totalAmount(expenses: List<Pair<String, Int>>): Int =
    expenses.fold(0) { acc, (_, amount) -> acc + amount }

fun minAmountOrNull(expenses: List<Pair<String, Int>>): Int? =
    expenses.fold(null as Int?) { acc, (_, amount) ->
        if (acc == null || amount < acc) amount else acc
    }

fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> =
    expenses.fold(mutableMapOf<String, Int>()) { acc, (category, amount) ->
        acc[category] = (acc[category] ?: 0) + amount
        acc
    }

І тепер main стає значно спокійнішим:

fun main() {
    val expenses = listOf(
        "food" to 120,
        "taxi" to 250,
        "food" to 80,
        "books" to 540
    )

    val total = totalAmount(expenses)
    val min = minAmountOrNull(expenses)
    val byCategory = totalsByCategory(expenses)

    println("Усього: $total")                 // Усього: 990
    println("Мінімальна витрата: $min")       // Мінімальна витрата: 80
    println("За категоріями: $byCategory")    // За категоріями: {food=200, taxi=250, books=540}
}

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

6. Типові помилки під час використання fold як патерна

Помилка № 1: неправильний initial (нейтральний елемент).
Дуже типова ситуація: ви хотіли порахувати добуток, але написали fold(0) { acc, x -> acc * x }, і отримали завжди нуль. Компілятор не свариться, програма працює, але результат гарантовано неправильний. Привчайте себе проговорювати: «яким має бути значення, якщо елементів немає?» — і від цього відштовхуйтеся, обираючи initial.

Помилка № 2: «я змінюю MutableMap, але забув повернути acc».
Якщо всередині fold ви робите акумулятором mutable-структуру, ви все одно зобовʼязані повернути щось із лямбди. Новачки інколи пишуть оновлення мапи, а останнім рядком випадково залишають вираз на кшталт acc[category] = ..., і тоді лямбда повертає Int?, а не мапу, — і код ламається на типах. Правило просте: наприкінці лямбди для mutable-акумуляторів явно пишіть acc.

Помилка № 3: спроба зробити «мінімум» через fold(Int.MAX_VALUE) без усвідомлення порожнього списку.
Це працює, доки список не порожній. Щойно список порожній — ви видаєте Int.MAX_VALUE як «мінімум», і далі він живе у звітах як начебто легальний результат. Набагато чесніше зробити акумулятор nullable й повертати null, якщо даних немає, або використовувати безпечні контракти на кшталт ...OrNull, бо порожній результат — це теж важлива інформація.

Помилка № 4: надто складна лямбда в fold, яка робить усе одразу.
fold — потужний, і тому зʼявляється спокуса: «зараз я і порахую, і відфільтрую, і відформатую, і ще у файл запишу». У підсумку всередині однієї лямбди опиняється мініпрограма. Якщо ви бачите, що лямбда розростається, зупиніться й винесіть шматки у функції або хоча б у локальні val усередині лямбди. fold любить ясність: (acc, x) -> новийAcc — без театру одного актора.

Помилка № 5: плутанина ролей acc і x (і «перевернута» логіка).
Іноді починають порівнювати acc із acc, додавати x до x — і виходить «щось дивне». Дисципліна імен справді допомагає: називайте параметри так, щоб ролі були очевидні: acc і expense, acc і amount, best і candidate. І не соромтеся робити деконструкцію пари прямо в параметрах — це знижує когнітивне навантаження.

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