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 | Чому так |
|---|---|---|---|
| Сума | |
|
Нуль не впливає на суму |
| Добуток | |
|
Одиниця не впливає на добуток |
| Конкатенація тексту | |
|
Порожній рядок не додає символів |
| Великий текст | |
|
Ми «добудовуємо» обʼєкт |
| Накопичення за ключем | |
|
Починаємо з порожньої структури |
Найнеприємніше тут те, що неправильний 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. І не соромтеся робити деконструкцію пари прямо в параметрах — це знижує когнітивне навантаження.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ