JavaRush /Курсы /Kotlin SELF /Чистые вычисления в отчётах: минимум эффектов и без мутац...

Чистые вычисления в отчётах: минимум эффектов и без мутации входа

Kotlin SELF
26 уровень , 2 лекция
Открыта

1. Почему отчётам нужны чистые вычисления

Отчёты — это такое место в программе, где разработчик особенно легко начинает «и считать, и печатать, и по пути чуть-чуть поправить данные». Кажется, что так быстрее: вот тут println, тут сортировка «на месте», тут какой-нибудь счётчик в var, который «и так ясно». Проблема в том, что отчёт обычно вызывается много раз и в разных сценариях, и любые скрытые эффекты потом превращаются в загадки уровня «почему сегодня сумма другая, хотя расходы те же».

В наших консольных проектах отчёт — это почти всегда производное представление данных. Он не должен менять сами данные, иначе вы начинаете жить в параллельных реальностях: сначала «данные как есть», потом «данные, которые отчёт случайно подправил». И угадайте, какая реальность победит? Обычно та, где баги.

Что такое чистая функция и какие эффекты мешают

Чистая функция — это очень земная идея: если вы дали ей одни и те же входные данные, она должна вернуть один и тот же результат. И при этом она не должна «делать что-то ещё» незаметно: печатать в консоль, менять глобальные переменные, переписывать входной список, зависеть от случайности или текущего времени.

С Kotlin это удобно ещё и потому, что язык подталкивает к аккуратной работе с коллекциями: есть read-only интерфейсы (List, Map) и есть mutable-варианты (MutableList, MutableMap). Read-only коллекция не даёт вам «случайно» поменять её содержимое, что снижает шанс поломать данные на ровном месте.

Печать и ввод внутри расчёта

Печать (println) — самый коварный побочный эффект, потому что он кажется безобидным: «ну я же просто посмотрю промежуточный результат». Но как только println оказывается внутри функции отчёта, функция перестаёт быть «считать и вернуть», и превращается в «считать и по пути шуметь». Потом вы вызываете отчёт из другого места — и внезапно консоль завалена сообщениями, которые вы уже забыли, откуда взялись.

Нормальная граница такая: функция расчёта возвращает данные (число, список пар, карту итогов), а печать происходит снаружи, в main или в слое CLI. Тогда отчёт можно тестировать «в голове»: вход → выход, без отвлекающих факторов.

Мутация внешнего состояния и почему var тут подозрителен

Второй класс эффектов — изменение чего-то «снаружи»: внешнего var, глобальной переменной, общей коллекции состояния приложения. В отчётах это особенно опасно, потому что отчёт обычно вызывается как «посмотреть текущее состояние», а не «изменить состояние». Если отчёт изменяет состояние, то он становится командой, хотя по смыслу он запрос.

var сам по себе не зло. Но если вы видите в отчёте внешний счётчик var total = 0, который живёт где-то «снаружи» и накапливает значения между вызовами, то это почти гарантированная будущая головоломка. «Почему вторая печать отчёта показывает сумму вдвое больше?» — потому что var помнит прошлую жизнь.

2. Граница эффектов: расчёт, рендер, печать

Чтобы отчёты были читаемыми и предсказуемыми, мы вводим простое разделение: сначала считаем данные, потом делаем из них красивый текст. Это не «архитектура ради архитектуры», а практическая экономия нервов: если числа неправильные, вы чините расчёт; если текст некрасивый, вы чините рендер. И эти задачи не мешают друг другу.

Удобно думать о границе так: «всё, что возвращает String, — это представление; всё, что возвращает Int, List<...>, Map<...> — это вычисление». Да, бывают исключения, но для нашего уровня и для консольного приложения это правило почти всегда делает код лучше.

Небольшая схема (в нашем трекере расходов):

flowchart LR
    A["Список расходов
List of Pair(String, Int)"] --> B["Расчёт отчёта
(чистые функции)"]
    B --> C["Итоговые данные
Int / Map / List of Pair"]
    C --> D["Рендер в строку
(StringBuilder локально)"]
    D --> E["println(...) в CLI"]

Смысл диаграммы простой: печать — только в самом конце, на границе программы. Всё до неё стараемся делать без побочных эффектов.

Разрешённая локальная мутация: StringBuilder и локальные MutableMap

Важно уточнение: «минимум побочных эффектов» не означает «никаких var никогда». Это означает: не мутируем внешнее состояние и входные данные, а если мутация нужна, то делаем её локально, под контролем, и возвращаем результат наружу.

Классический пример — StringBuilder. Он мутируется (в него добавляются кусочки текста), но это происходит внутри функции рендера, и наружу функция отдаёт готовую строку. Это хороший компромисс: эффективно и читабельно.

Ещё один пример — локальная MutableMap внутри функции агрегации. Если вы создаёте MutableMap внутри функции и заполняете её, не трогая внешние переменные и не возвращая саму мутабельную ссылку «для дальнейших приключений», это обычно допустимо. Смысл тот же: локальная мутация ради результата, который вы вернёте как Map.

3. fold как «чистый счётчик» вместо внешних var

Когда новичок хочет посчитать сумму, он почти всегда пишет счётчик и цикл. Это нормально, и мы так делали раньше. Но в отчётах удобнее выражать накопление результата через fold: тогда накопление происходит «внутри выражения», а не через внешнюю мутацию. Технически внутри fold тоже есть накопитель, но он локален и не протекает наружу. В итоге ваш код легче читать и сложнее случайно сломать.

Сравним два подхода.

Вариант с внешним var:

fun calcTotalBad(expenses: List<Pair<String, Int>>): Int {
    var total = 0
    for (e in expenses) {
        total += e.second
    }
    return total
}

Он работает. Но если вы начнёте «для удобства» печатать внутри цикла, или вынесете total куда-то наружу — вы быстро попадёте в мир неожиданных эффектов.

Вариант через fold:

fun calcTotal(expenses: List<Pair<String, Int>>): Int =
    expenses.fold(0) { acc, item -> acc + item.second }

Здесь читается буквально: «сверни список в одно число, начиная с нуля». И, что важно, эта функция не умеет «случайно» сохранить состояние между вызовами — у неё нет места, где оно могло бы жить.

Мини-проверка:

fun main() {
    val expenses = listOf("food" to 10, "taxi" to 20)

    println(calcTotal(expenses)) // 30
    println(calcTotal(expenses)) // 30
}

Дважды вызвали — дважды получили одинаковое. И отчёт не «устал» между вызовами.

4. Не мутируем входные данные

Проблема «мутации входа» в отчётах часто выглядит так: у вас есть список расходов в состоянии приложения (обычно MutableList), вы передали его в отчёт, а отчёт решил «для удобства» что-то в нём поменять. Например, удалить некорректные записи, отсортировать, нормализовать категории прямо в исходном списке. А потом пользователь возвращается к списку и видит, что данные изменились — хотя он ничего не делал, он всего лишь попросил отчёт.

Чтобы этого не происходило, держим два правила.

Первое правило — принимать в расчётных функциях read-only типы: List, Map, а не MutableList, MutableMap. Даже если у вас внутри приложения хранится MutableList, наружу (в отчёт) вы отдаёте его как List. Это не делает коллекцию «магически неизменяемой», но сильно снижает шанс, что вы случайно начнёте её менять, потому что компилятор просто не даст.

Второе правило — если вам очень нужно «изменять данные для расчёта», вы делаете преобразование, которое возвращает новый результат (map, filter, sorted и т.п.), а не переписываете входной список. Трансформации коллекций в Kotlin по умолчанию строят новую коллекцию, а не мутируют исходную, и это как раз то, что нам нужно для отчётов.

Простой пример нормализации без мутации входа:

fun normalizeCategory(raw: String): String =
    raw.trim().lowercase()

fun normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> =
    expenses.map { (cat, amount) -> normalizeCategory(cat) to amount }

Мы ничего не меняем «внутри» expenses, а создаём новый список. Старый остаётся как был.

5. Чистые отчёты в мини‑трекере расходов

Теперь соберём это в практику: представим, что у нас уже есть консольный трекер расходов, который хранит операции как пары (категория, сумма). Мы хотим писать отчёты так, чтобы они не печатали внутри себя, не правили входные данные и не зависели от внешних переменных. При этом отчёты должны быть легко комбинируемыми: один считает числа, другой делает текст, третий печатает.

Мы будем развивать один и тот же стиль: «чистые вычисления → чистый рендер → эффект (println) в main». Это поможет нам дальше собирать набор отчётов, не превращая main в свалку логики.

Форма данных и нормализация

Начнём с того, что закрепим: расход — это Pair<String, Int>, где first — категория, second — сумма. Категория может прийти «грязной» (пробелы, разный регистр), поэтому мы нормализуем её заранее. Нормализацию удобно сделать отдельной функцией, потому что она повторяется в каждом отчёте.

fun normalizeCategory(raw: String): String =
    raw.trim().lowercase()

fun normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> =
    expenses.map { (cat, amount) -> normalizeCategory(cat) to amount }

Проверим на мини-наборе:

fun main() {
    val raw = listOf(" Food " to 120, "FOOD" to 80, "taxi" to 250)
    val normalized = normalizeExpenses(raw)

    println(normalized) // [(food, 120), (food, 80), (taxi, 250)]
}

Важный момент: raw не изменился, мы создали новый список. Это и есть «без мутации входа».

Чистые функции расчёта: total и totalsByCategory

Теперь напишем чистые функции расчёта. Начнём с общей суммы. Мы уже обсуждали fold: он идеально подходит, потому что выражает «накопление результата» без внешних счётчиков.

fun calcTotal(expenses: List<Pair<String, Int>>): Int =
    expenses.fold(0) { acc, item -> acc + item.second }

Проверка:

fun main() {
    val expenses = listOf("food" to 10, "taxi" to 20, "books" to 5)
    println(calcTotal(expenses)) // 35
}

Теперь суммы по категориям. У нас есть два читаемых пути, оба без мутации входа.

Первый путь: groupBy + mapValues. groupBy превращает список в Map<K, List<V>>, где ключ — категория, значение — список записей этой категории. Это «переломный момент» пайплайна, когда форма данных меняется радикально.

fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> {
    val grouped = expenses.groupBy { it.first } // Map<String, List<Pair<String, Int>>>

    return grouped.mapValues { (_, items) ->
        items.fold(0) { acc, item -> acc + item.second }
    }
}

Проверка:

fun main() {
    val expenses = listOf("food" to 10, "food" to 5, "taxi" to 7)
    println(totalsByCategory(expenses)) // {food=15, taxi=7}
}

mapValues — это стандартная трансформация карт, которая строит новую карту, не меняя исходную. Мы получили итоговую структуру Map<String, Int>, и дальше отчёт может работать уже с числами, а не с исходными записями.

Второй путь (иногда удобнее, когда хочется один проход): аккуратно сворачиваем список в карту через fold, используя локальную MutableMap. Это локальная мутация, но она полностью спрятана внутри функции и не трогает входной список. В конце мы возвращаем Map, как «итоговые данные».

fun totalsByCategoryFold(expenses: List<Pair<String, Int>>): Map<String, Int> =
    expenses.fold(mutableMapOf()) { acc, (cat, amount) ->
        acc[cat] = (acc[cat] ?: 0) + amount
        acc
    }

Проверка:

fun main() {
    val expenses = listOf("food" to 10, "food" to 5, "taxi" to 7)
    println(totalsByCategoryFold(expenses)) // {food=15, taxi=7}
}

Оба варианта корректны. В учебных примерах чаще проще читать первый (через groupBy), потому что он буквально показывает шаги: «сгруппировали → посчитали сумму в каждой группе».

Чистый рендер: превращаем итоговые данные в строку

Теперь сделаем функцию, которая не считает, а красиво оформляет. Её задача — взять уже посчитанные строки/числа и вернуть String. Здесь допустима локальная мутация StringBuilder, потому что она ограничена одной функцией и даёт предсказуемый итог.

fun renderTotals(rows: List<Pair<String, Int>>): String {
    val sb = StringBuilder()

    for ((category, sum) in rows) {
        sb.append(category).append(": ").append(sum).append('\n')
    }

    return sb.toString()
}

Проверим:

fun main() {
    val rows = listOf("taxi" to 250, "food" to 200)
    print(renderTotals(rows))
    // taxi: 250
    // food: 200
}

Обратите внимание: renderTotals ничего не печатает сам. Он отдаёт строку, а печать — решение вызывающего кода. Это и есть аккуратная граница эффектов.

6. Типичные ошибки при «чистых» отчётах

Ошибка №1: отчёт «считает» через println.
Часто встречается стиль, где «посчитал и сразу вывел», а иногда ещё и по пути распечатал промежуточные значения «для отладки». В момент написания это кажется удобным, но потом отчёт невозможно переиспользовать: вы захотите показать его в другом формате или объединить с другим отчётом, а он уже шумит в консоль. Лекарство простое: расчёт возвращает данные, рендер возвращает строку, печать живёт снаружи.

Ошибка №2: функция отчёта принимает MutableList и меняет её «для удобства».
Это особенно коварно, потому что по смыслу отчёт — «посмотреть», а не «изменить». Если внутри отчёта вы удаляете элементы, правите категории или меняете порядок, то после вызова отчёта состояние приложения уже другое. Обычно это всплывает как «плавающий» баг: отчёт вызвали один раз — всё ок, вызвали второй — всё странно. Помогает принимать List и возвращать новый результат, а не править вход.

Ошибка №3: внешний счётчик в var, который живёт дольше одного вызова.
Иногда «для ускорения» или «для простоты» сумму кладут в переменную снаружи, а отчёт её дополняет. Через пару вызовов вы получаете накопление «за все времена», хотя хотели «за текущие данные». fold решает это почти всегда: он выражает накопление локально и не даёт случайно сохранить прошлое между вызовами.

Ошибка №4: смешивание нормализации и форматирования в одном месте.
Когда внутри рендера вы вдруг начинаете делать trim().lowercase() и ещё заменять пробелы, отчёты становятся несогласованными: где-то категория нормализована, где-то нет. В итоге один отчёт покажет Food, другой food, третий вообще food . Лучше договориться: нормализация — ранний шаг, общий для всех отчётов, и он возвращает «чистые данные», а форматирование — финальный шаг и работает уже с нормализованным.

Ошибка №5: возвращать «почти готовый текст» слишком рано и терять возможность дообработки.
Если вы слишком рано превращаете данные в строки, то потом неудобно сортировать, фильтровать по порогу, брать top‑N и так далее: приходится парсить обратно или городить дополнительные структуры. Хорошая привычка — держать данные числами и парами до самого конца, и только последним шагом делать String. Тогда отчёт остаётся гибким и его легко расширять следующими шагами пайплайна.

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