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>>, і це зручно явно зафіксувати.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ