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: $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. И не стесняйтесь делать деконструкцию пары прямо в параметрах — это снижает когнитивную нагрузку.

1
Задача
Kotlin SELF, 25 уровень, 1 лекция
Недоступна
Сумма из строки
Сумма из строки
1
Задача
Kotlin SELF, 25 уровень, 1 лекция
Недоступна
Плюсы в отчёте
Плюсы в отчёте
1
Задача
Kotlin SELF, 25 уровень, 1 лекция
Недоступна
Минимальная цена
Минимальная цена
1
Задача
Kotlin SELF, 25 уровень, 1 лекция
Недоступна
Текстовый чек
Текстовый чек
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ