JavaRush /Курси /Kotlin SELF /fold і reduce: старт, порожні колекції та безпечні варіан...

fold і reduce: старт, порожні колекції та безпечні варіанти

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

1. Агрегації: з колекції — в один результат

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

І тут часто трапляється типова «магія новачка»: людина заводить зовнішню var sum = 0, потім пише forEach { sum += it } — і гордо вважає завдання розвʼязаним. Формально — так. Але за стилем і надійністю це схоже на перенесення води в долонях: донести можна, та шанс розлити — великий, а руки потім липкі.

fold і reduce дають дисципліну: ми явно кажемо Kotlinʼу «ось колекція, ось правило накопичення — поверни мені підсумок». Це й називається агрегація: «багато елементів → один результат».

Офіційне формулювання просте: fold() і reduce() послідовно застосовують операцію, яка отримує накопичене значення та черговий елемент і повертає новий накопичений результат.

2. Акумулятор: як читати (acc, x) -> новийAcc

Коли ви вперше бачите fold(0) { acc, x -> acc + x }, це може скидатися на заклинання. Спокійно: це не магія. Це звичайний цикл, просто акуратно «запакований» у функцію.

Головна ідея — акумулятор (зазвичай його називають acc, від accumulator). Це значення, яке накопичується крок за кроком. У кожен момент часу воно зберігає проміжний підсумок.

Уявіть скарбничку. Кожен елемент колекції — це монетка. acc — це те, скільки вже лежить у скарбничці. На кожному кроці ви робите одну й ту саму дію: берете чергову монетку й оновлюєте суму.

Це можна навіть намалювати як мінісхему:

flowchart LR
    A["initial / старт"] --> B["acc після 1-го елемента"]
    B --> C["acc після 2-го елемента"]
    C --> D["acc після 3-го елемента"]
    D --> E["підсумок"]

Зміст лямбди у fold/reduce завжди один і той самий: «як із (acc і x) отримати наступний acc».

Є ще один важливий момент: лямбда має повертати нове значення акумулятора. У Kotlin це означає, що останній вираз у лямбді й є поверненим результатом (якщо ви не пишете return, що всередині лямбд майже ніколи не потрібно).

Якщо хочеться побачити «нутрощі» fold, у документації трапляється майже буквальна реалізація через цикл: спочатку accumulator = initial, потім для кожного елемента accumulator = combine(accumulator, element), а наприкінці повертається accumulator. Тобто це не магія, а просто акуратно «запакований» for.

3. fold: старт, порожні колекції та нейтральний елемент

До цього моменту багато операцій із колекціями були «безпечні за контрактом»: filter на порожньому списку повертає порожній список, count поверне 0 — і нічого не впаде. fold продовжує цю філософію: він завжди визначений, бо має стартове значення initial.

Ключове формулювання таке: fold(initial) на першому кроці вважає, що поточне накопичене значення дорівнює initial, і лише потім починає «вбирати» елементи колекції. Саме цим він відрізняється від reduce.

Найбазовіший приклад: сума чисел

Сума — це класика жанру, бо для неї є хороший нейтральний старт: 0.

fun main() {
    val xs = listOf(1, 2, 3)

    val sum = xs.fold(0) { acc, x -> acc + x }
    println(sum) // 6
}

Людською мовою це читається так: «почни з 0 і для кожного x додай його до acc».

Чому fold хороший на порожніх списках

Ось місце, де fold справді приємно «видихає»: порожній список не влаштовує драму. Просто «нічого не додали до старту» — і підсумок дорівнює стартовому значенню.

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

    val sum = empty.fold(0) { acc, x -> acc + x }
    println(sum) // 0
}

Це не баг. Це контракт: fold завжди повертає значення, бо в нього завжди є initial.

Колекція
fold(initial)
Чому
порожня
initial
не було жодного кроку оновлення
непорожня накопичений підсумок
initial
оновлювався на кожному елементі

Нейтральний елемент: чому старт має мати сенс

Коли новачок дізнається про fold(0), у нього може зʼявитися небезпечна думка: «ага, значить старт завжди 0». Так і народжуються застосунки, які працюють стабільно, але неправильно. Це особливо підступно: компілятор мовчить, помилок немає — а результат сміттєвий. Компʼютер узагалі чудово рахує нісенітниці. Це одна з його сильних сторін.

Сенс стартового значення — бути нейтральним елементом (або принаймні логічним стартом) для вашої операції.

Якщо ви рахуєте добуток, старт 0 перетворить результат на 0 назавжди, бо 0 * що завгодно = 0. Тому беремо 1.

fun main() {
    val xs = listOf(2, 3, 4)

    val product = xs.fold(1) { acc, x -> acc * x }
    println(product) // 24
}

Старт може бути не числом — і це нормально. fold уміє накопичувати будь-який тип, бо старт задаєте ви. Важливо розуміти вже зараз: initial задає не лише значення, а й часто тип результату.

4. reduce: старт = перший елемент і наслідки

reduce виглядає як «fold, тільки коротше»: стартове значення не треба писати. І саме тому reduce — водночас зручний і трохи небезпечний інструмент.

Контракт reduce такий: він не приймає initial. Отже, на першому кроці йому треба звідкись узяти початковий acc. Він бере його з колекції: перший елемент стає початковим акумулятором, а операція вперше застосовується до пари (перший елемент, другий елемент).

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

Сума через reduce (коли список точно не порожній)

fun main() {
    val xs = listOf(1, 2, 3)

    val sum = xs.reduce { acc, x -> acc + x }
    println(sum) // 6
}

Це працює, бо сума асоціативна, і старт «перший елемент» тут логічний.

Пастка: перший елемент обробився не так, як інші

Класичний приклад — «підсумувати подвоєні елементи». Для fold усе чесно: кожен елемент подвоюється. Для reduce — ні, бо перший елемент раптово перетворюється на стартовий acc без подвоєння.

Порівняймо:

fun main() {
    val xs = listOf(5, 2, 10, 4)

    val ok = xs.fold(0) { acc, x -> acc + x * 2 }
    val tricky = xs.reduce { acc, x -> acc + x * 2 }

    println(ok)     // 42
    println(tricky) // 37
}

Чому так?
reduce стартує з acc = 5, а потім рахує 5 + 2*2 + 10*2 + 4*2. Перший 5 не був подвоєний, бо він узагалі не проходив через «звичайне правило» — він просто став стартом.

Це не «підстава Kotlin», а прямий наслідок контракту reduce.

Найважливіша небезпека: порожня колекція

Оскільки reduce бере старт із першого елемента, на порожній колекції це неможливо. Тому reduce на порожній колекції викине виняток (тобто застосунок упаде, якщо ви цього не обробите).

5. Безпечні варіанти та вибір підходу

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

reduceOrNull: результат може бути відсутній

reduceOrNull { ... } робить рівно це: якщо колекція порожня — повертає null, якщо не порожня — працює як reduce.

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

    val sum = empty.fold(0) { acc, x -> acc + x }
    val reduced = empty.reduceOrNull { acc, x -> acc + x }

    println(sum)     // 0
    println(reduced) // null
}

Смисловий переклад:

  • fold каже: «результат є завжди, навіть якщо даних немає (це старт)».
  • reduceOrNull каже: «якщо немає даних — немає результату».

Обидва підходи нормальні — вибір залежить від задачі. Для суми часто зручно мати «0», бо 0 означає «нічого не було». А от для, скажімо, «мінімальної покупки» 0 уже може бути неправдою, бо покупка на 0 ₴ — підозріла навіть під час розпродажів.

Практичне правило вибору

Якщо в операції є природний нейтральний старт (сума → 0, добуток → 1, рядок → "", список → emptyList() тощо), то fold зазвичай читається й працює надійніше: він визначений навіть на порожніх даних.

Якщо старт «перший елемент» логічний і ви гарантуєте непорожню колекцію за змістом застосунку, тоді reduce можна використовувати.

Але щойно гарантія стає сумнівною (ввід користувача, фільтрація, пошук), краще одразу перемикатися на reduceOrNull, щоб контракт був чесним і безпечним.

6. Приклад із консольного застосунку: рахуємо підсумки

Зараз ми акуратно застосуємо fold/reduceOrNull у нашому «практичному» консольному застосунку, який ми розвивали на уроках про колекції та команди. Нагадаю модель: у нас є список операцій (наприклад, витрат), які ми поки зберігаємо без ООП — просто як пари Pair<String, Int>, де first — категорія, second — сума.

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

Тип для читабельності (поки без класів)

typealias Entry = Pair<String, Int>

Так, це все ще Pair, але тепер у коді менше шуму: List<Entry> читається легше, ніж List<Pair<String, Int>>.

Загальна сума через fold(0)

typealias Entry = Pair<String, Int>

fun totalAmount(entries: List<Entry>): Int =
    entries.fold(0) { acc, (_, amount) -> acc + amount }

fun main() {
    val entries = listOf("food" to 120, "taxi" to 250)
    println(totalAmount(entries)) // 370
}

Зверніть увагу на приємну річ: якщо entries порожній, totalAmount усе одно коректно поверне 0. І вам не потрібно городити if (entries.isEmpty()) ...

Найбільша операція через reduceOrNull

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

typealias Entry = Pair<String, Int>

fun maxEntryOrNull(entries: List<Entry>): Entry? =
    entries.reduceOrNull { acc, x ->
        if (x.second >= acc.second) x else acc
    }

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

    val max = maxEntryOrNull(entries)
    println(max) // (taxi, 250)
}

Як друкувати результат, не забуваючи про null

typealias Entry = Pair<String, Int>

fun maxEntryOrNull(entries: List<Entry>): Entry? =
    entries.reduceOrNull { acc, x -> if (x.second >= acc.second) x else acc }

fun main() {
    val entries = emptyList<Entry>()

    val max = maxEntryOrNull(entries)
    println(max?.second ?: 0) // 0
}

Тут ми не стверджуємо, що максимальна покупка — «0». Ми лише кажемо: «якщо максимуму немає, то для виводу покажу 0».

Це тонка, але важлива різниця: внутрішній контракт функції чесний, а форматування виводу — окреме рішення.

7. Типові помилки під час роботи з fold і reduce

Помилка №1: використовувати reduce там, де список може виявитися порожнім.
Зазвичай це стається не «тому що людина погана», а тому що ви застосували фільтр, а потім забули: фільтр може відкинути все. Підсумок — падіння на рівному місці. Звичка проста: якщо немає залізобетонної гарантії непорожнечі, беріть fold (коли є нормальний initial) або reduceOrNull (коли результату може не бути).

Помилка №2: обирати неправильний initial і отримувати «стабільно неправильний» результат.
fold(0) для суми — чудово. fold(0) для добутку — гарантований нуль. fold(Int.MAX_VALUE) для мінімуму інколи працює, але виглядає як магічне число й ламається, якщо ви зміните тип або діапазон даних. Сенс стартового значення варто проговорювати: що саме воно означає і чому не спотворює результат.

Помилка №3: вважати, що reduce — «те саме, тільки коротше», і не враховувати перший елемент.
Пастка проявляється в задачах на кшталт «сума подвоєних елементів»: перший елемент у reduce стає стартом і не проходить через ваше правило обробки. Однакова лямбда в fold і reduce може дати різний результат — саме через контракт старту.

Помилка №4: плутати ролі параметрів у лямбді й випадково писати правило «навпаки».
У fold/reduce перший параметр — це накопичене (acc), другий — черговий елемент (x). Якщо називати їх як завгодно (a, b, c), мозок починає помилятися вже на третьому рядку. Проста дисципліна імен acc і x (або acc і item) справді зменшує кількість багів.

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

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