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: $total") // 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("Big expenses: $bigCount") // Big expenses: 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("Min amount: $minAmount") // Min amount: 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("Min amount: $minAmount") // Min amount: 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("Total: ").append(total).append('\n')
.toString()
print(report)
// - food: 120
// - taxi: 250
// - food: 80
// Total: 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: $total") // Total: 990
println("Min expense: $min") // Min expense: 80
println("By category: $byCategory") // By category: {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. И не стесняйтесь делать деконструкцию пары прямо в параметрах — это снижает когнитивную нагрузку.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ