JavaRush /Курсы /Kotlin SELF /Декомпозиция пайплайна: функции шагов и промежуточные val...

Декомпозиция пайплайна: функции шагов и промежуточные val

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

1. Почему длинные цепочки не всегда читаемы

Когда вы впервые открываете для себя операции коллекций, появляется соблазн написать всё “в одну строку”: и нормализацию, и фильтрацию, и группировку, и сортировку, и форматирование. Код получается компактный, как вакуумная упаковка… но иногда также труднораспаковываемый, как эта самая вакуумная упаковка.

Проблема не в том, что “цепочки — это плохо”. Проблема в том, что у цепочки, как у змейки-игрушки, есть критическая длина: до неё всё мило, после неё вы уже сами не уверены, где голова, а где хвост. И если через неделю вас попросят “чуть-чуть поменять” правило фильтрации — вы будете смотреть на код так, как люди смотрят на инструкцию к микроволновке на японском.

Давайте начнём с простого примера. Представим, что наш учебный консольный проект (условный “учёт расходов”) хранит операции так же, как мы договорились в планах дня: List<Pair<String, Int>>, где first — категория, second — сумма.

fun main() {
    val expenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        " taxi" to 250,
        "Books" to 90
    )

    val report = expenses
        .map { it.first.trim().lowercase() to it.second }
        .groupBy { it.first }
        .mapValues { (_, items) -> items.fold(0) { acc, item -> acc + item.second } }
        .entries
        .map { it.key to it.value }
        .sortedByDescending { it.second }
        .take(3)
        .joinToString(separator = "\n") { (cat, sum) -> "$cat: $sum" }

    println(report)
}

Работает? Да. Читается? Ну… “по-своему”. Здесь сразу видно ключевую боль: у нас в одном месте смешались разные уровни логики. map и groupBy — про данные, sortedByDescending и take — про то, как показывать, joinToString — уже про текст. А ещё внутри mapValues спрятался fold, который сам по себе важный этап агрегации.

Наша цель — сделать так, чтобы пайплайн читался как “история”: что мы сделали сначала, что потом, и какой смысл у каждого шага.

2. Инструменты декомпозиции пайплайна

Когда цепочка начинает “расти”, есть два самых простых и мощных инструмента, которые спасают читаемость. Причём спасают без магии, без новых библиотек и без философии “функционального дзена”. Всё максимально приземлённо: вы просто добавляете понятные имена и точки остановки.

Первый инструмент — промежуточные val. Это “остановки” в пайплайне: вы берёте результат важного шага, кладёте в переменную с говорящим именем и идёте дальше. Получается код, где каждый val — как заголовок главы в книге.

Второй инструмент — функции шагов. Если операция повторяется в нескольких отчётах (например, нормализация категории), или если шаг сам по себе сложный, его выносят в отдельную функцию с понятным контрактом “вход → выход”. Это особенно хорошо, когда вы хотите, чтобы main оставался коротким и читался как сценарий.

Важно, что оба инструмента решают одну и ту же проблему, но с разных сторон. val удобен, когда шаг нужен один раз и важен контекст прямо здесь. Функция удобна, когда шаг хочется назвать и переиспользовать, а также когда вы хотите скрыть технические детали. В Kotlin это ложится естественно, потому что операции коллекций — это обычные функции, часто принимающие лямбды.

Промежуточные val как точки остановки

Промежуточные val — это самый быстрый способ сделать код понятнее уже сегодня, не меняя архитектуру проекта. Мысленно это похоже на то, как вы решаете задачу по математике: вместо того чтобы сразу писать финальную формулу на полстраницы, вы сначала выписываете “пусть A = …”, “пусть B = …”, и дальше всё становится прозрачнее.

Переделаем наш пример отчёта в несколько val. Обратите внимание: логика почти та же, но чтение теперь линейное.

fun main() {
    val expenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        " taxi" to 250,
        "Books" to 90
    )

    val normalized = expenses.map { (cat, amount) ->
        cat.trim().lowercase() to amount
    }

    val totalsByCategory = normalized
        .groupBy { (cat, _) -> cat }
        .mapValues { (_, items) -> items.fold(0) { acc, item -> acc + item.second } }

    val top3 = totalsByCategory
        .entries
        .map { it.key to it.value }
        .sortedByDescending { (_, sum) -> sum }
        .take(3)

    val report = top3.joinToString("\n") { (cat, sum) -> "$cat: $sum" }

    println(report)
}

Здесь есть важный эффект: каждый val — это “контроль типа”. Вы глазами видите, что после нормализации у вас всё ещё List<Pair<String, Int>>, после группировки и mapValues у вас Map<String, Int>, а после сортировки вы получили список пар для вывода.

И ещё одна деталь: sortedByDescending мы применили к списку пар, а не пытались “сортировать Map”. Это типичная реальность: Map не про порядок, поэтому часто перед сортировкой вы переходите к entries и превращаете их в список. И если вам встретится соблазн “а давай я просто сделаю sort()” — помните разницу: sort() меняет mutable-коллекцию на месте, sorted()/sortedBy... возвращают новый результат.

Небольшая схема пайплайна

Чтобы лучше видеть структуру, полезно иногда рисовать её как цепочку типов:

List<Pair<String, Int>>
  → (normalize)
List<Pair<String, Int>>
  → (groupBy + sum)
Map<String, Int>
  → (entries → sort → take)
List<Pair<String, Int>>
  → (render)
String

Когда вы держите в голове эту “лестницу типов”, вы меньше путаетесь, что можно сортировать, что можно группировать, а что уже “готовые числа”.

Функции шагов

Промежуточные val отлично работают, пока вы пишете один отчёт. Но как только отчётов становится несколько, вы начнёте копировать куски: нормализация повторяется, “сумма по категориям” повторяется, рендер повторяется. И вот тут начинается классическая история: “я чуть-чуть поправил в одном месте, а в другом забыл”. Программа как бы намекает, что пора вынести повторяемые шаги в функции.

Функция шага — это маленькая функция с одной ответственностью и ясной сигнатурой. Её можно читать как фразу: normalizeExpenses, totalsByCategory, topCategories, renderLines. Хорошая функция шага обычно не печатает в консоль и не читает ввод — она просто принимает данные и возвращает новые данные.

Начнём с самого повторяемого: нормализация категории. Это простой шаг, но он часто нужен во всех отчётах.

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

Теперь применим её в нормализации списка операций.

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

Операция map — базовая операция трансформации коллекции: она строит новую коллекцию из результатов преобразования элементов.

Теперь вынесем “суммы по категориям” в отдельную функцию. Мы продолжим использовать groupBy, который даёт Map<String, List<Pair<String, Int>>>, а затем сведём каждую группу в сумму. fold здесь хорошо подходит, потому что у него есть стартовое значение, и он безопасен на пустых списках.

fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> {
    val grouped = expenses.groupBy { (cat, _) -> cat }
    return grouped.mapValues { (_, items) ->
        items.fold(0) { acc, item -> acc + item.second }
    }
}

И, наконец, шаг “подготовить top-N”. Он должен работать уже с агрегированными данными (Map<String, Int>), потому что top по “сырым операциям” — это совсем другой смысл.

fun topCategories(totals: Map<String, Int>, n: Int): List<Pair<String, Int>> {
    return totals.entries
        .map { it.key to it.value }
        .sortedByDescending { (_, sum) -> sum }
        .take(n)
}

take(n) — тот самый простой приём top‑N: сначала сортируем, потом берём первые n.

Теперь рендеринг. Поскольку нам важно держать “расчёт” отдельно от “текста”, рендер — это отдельная функция, которая возвращает String. Для сборки строк удобно использовать StringBuilder.

import kotlin.text.StringBuilder

fun renderCategoryTotals(rows: List<Pair<String, Int>>): String {
    val sb = StringBuilder()
    for ((cat, sum) in rows) {
        sb.append(cat).append(": ").append(sum).append('\n')
    }
    return sb.toString()
}

3. Читаемый сценарий в main

Когда шаги выделены, main начинает выглядеть как сценарий, а не как лабораторный журнал из хаотичных формул. И это важный психологический эффект: вы читаете код сверху вниз и понимаете намерение без того, чтобы вглядываться в каждую лямбду.

Соберём всё вместе на небольших данных:

fun main() {
    val rawExpenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        " taxi" to 250,
        "Books" to 90
    )

    val normalized = normalizeExpenses(rawExpenses)
    val totals = totalsByCategory(normalized)
    val top3 = topCategories(totals, n = 3)
    val report = renderCategoryTotals(top3)

    println(report)
}

Здесь особенно приятна одна штука: если вы захотите добавить ещё один отчёт (например, “все категории по алфавиту”), вы не будете переписывать полпайплайна. Вы просто подставите другую функцию “представления” данных после totals. А если вам поменяют бизнес-правило нормализации (например, “заменять пробелы на _” или “убрать точки”), вы правите одну функцию normalizeCategory.

4. Контракты и имена шагов

Есть полезная привычка, которая быстро делает код взрослым: смотреть не только на тело функции, но и на её “внешний контракт” — имя, параметры и возвращаемый тип. Хорошая функция шага читается почти без тела: вы видите, что на входе, что на выходе, и понимаете, на каком месте пайплайна она живёт.

В Kotlin это особенно прозрачно на примере fold: он принимает стартовое значение и функцию-комбайнер, и возвращает накопленный результат. То есть уже из сигнатуры ясно: это агрегация, которая превращает много элементов в одно значение.

Для наших шагов та же идея:

  • normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> — тип не меняется, меняется качество данных.
  • totalsByCategory(...): Map<String, Int> — “переломный момент”: из списка операций переходим в агрегаты.
  • topCategories(...): List<Pair<String, Int>> — готовим представление (сортировка и ограничение).
  • renderCategoryTotals(...): String — превращаем данные в текст.

Если вы в какой-то момент ловите себя на функции вида fun doReportStuff(x: Any): Any, то это не “универсальность”, а сигнал “мы потеряли структуру”.

С переменными и функциями в пайплайнах работает простое правило: имя должно объяснять смысл шага, а не внутреннюю механику. Если вы назвали переменную tmp2, вы сообщаете будущему себе: “я не знаю, что это, удачи”. Если вы назвали totalsByCategory, вы буквально подписали, что внутри.

Ниже — маленькая таблица, которую полезно держать в голове, когда рука тянется к data2:

Плохо (скрывает смысл) Хорошо (объясняет шаг)
tmp, tmp2, res
normalized, filtered, totalsByCategory
process(), handle()
normalizeCategory(), topCategories()
map1, map2
totals, counts, categoryToSum

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

5. Типичные ошибки

Ошибка №1: вынос “шага” в функцию, которая ещё и печатает.
Поначалу хочется сделать так: fun totalsByCategory(...) { ... println(...) }. Но тогда вы теряете гибкость: вы не сможете использовать результат в другом отчёте без лишнего вывода. Гораздо устойчивее, когда шаги возвращают данные, а вывод делается отдельно, в конце.

Ошибка №2: промежуточные val, которые ничего не проясняют.
Иногда студент честно добавляет val a = ...; val b = ...; val c = ..., но имена настолько абстрактные, что стало даже хуже. Декомпозиция работает только вместе с осмысленными именами: normalized, totals, top3, reportText.

Ошибка №3: функция “на всё”, которая делает нормализацию, фильтр, сортировку и рендер сразу.
Такой комбайн кажется удобным, пока он один. Потом вы добавляете второй отчёт — и понимаете, что переиспользовать нечего. Пайплайн хорошо живёт, когда у каждого шага один тип результата и одна ответственность: преобразовали список, получили карту, получили top‑список, получили строку.

Ошибка №4: потеря контроля типов из-за отсутствия “точек остановки”.
В длинной цепочке легко перестать понимать, что именно у вас сейчас: List? Map? Sequence? String? Промежуточные val часто нужны не “для красоты”, а чтобы вы в любой момент могли сказать: “на этом шаге у меня Map<String, Int>”. groupBy как раз тот шаг, где тип резко меняется на Map<K, List<V>>, и это удобно фиксировать явно.

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