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(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 USDT — это подозрительно даже для распродаж.
Практическое правило выбора
Если у операции есть естественный нейтральный старт (сумма → 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. Хороший ориентир — помнить, что по сути это короткий цикл: логика должна быть «размером с цикл», а не «размером с диплом».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ