JavaRush /Курси /Kotlin SELF /Читабельні ланцюжки операцій — результат без мутації поча...

Читабельні ланцюжки операцій — результат без мутації початкової колекції

Kotlin SELF
Рівень 22 , Лекція 4
Відкрита

1. Навіщо писати ланцюжки, а не цикли

Якщо ви коли-небудь писали код на кшталт «створити порожній список → пройти циклом → у if додати → потім ще раз пройти циклом», то вам знайоме відчуття, коли поступово зʼявляється думка: програма — як каструля. Ми постійно щось помішуємо й боїмося розхлюпати. Ланцюжки викликів у Kotlin дають змогу описувати обробку даних як послідовність кроків. Кожен крок повертає результат, а «трубопровід» (pipeline) видно прямо в коді.

Ключова ідея цієї лекції проста: ми намагаємося не мутувати початкову колекцію, а будувати нові результати поверх неї. Тобто замість «взяли список витрат і видалили з нього все зайве» робимо «взяли список витрат і отримали новий список — лише те, що потрібно». Так код стає передбачуванішим: ви менше хвилюєтеся, що десь «там» список уже змінили, і налагодження не перетворюється на полювання на привидів.

Невеличка таблиця для наочності:

Операція Що повертає Мутує початкову колекцію? Типова роль
filter { ... }
List<T>
ні «залишити потрібне»
map { ... }
List<R>
ні «перетворити на інше»
find { ... }
T?
ні «знайти перше потрібне»
any/all
Boolean
ні «швидка перевірка»
count { ... }
Int
ні «скільки елементів підходить»
forEach { ... }
Unit
ні «виконати дію наприкінці»

2. Каркас читабельного ланцюжка операцій

Коли ви щойно починаєте писати ланцюжки, легко піддатися спокусі зробити їх «якнайкоротшими». А потім — із гордістю дивитися на 12 викликів поспіль і думати: «Я функціональний геній». Зазвичай саме в цей момент ваші «майбутні ви» дістають табличку «за що?» і мовчки показують її вам. Тож нам потрібен простий, повторюваний каркас, за яким ланцюжок читається як розповідь: спочатку підготували дані, потім відібрали, далі перетворили, потім отримали підсумок і (за потреби) вивели.

Зафіксуймо це як схему. Не як догму, а як зручний ритм читання коду:

flowchart TD
    A[Джерело даних: List⟨T⟩] --> B[Нормалізація: map / trim / lowercase]
    B --> C[Відбір: filter]
    C --> D[Перетворення: map / mapNotNull]
    D --> E[Підсумок: find / any / all / count]
    E --> F[Дія: forEach / println]

Тут важливо, що forEach найчастіше стоїть у самому кінці, коли ви вже отримали те, що потрібно порахувати або підготувати. forEach — це «вивести на екран», «записати в лог», «викликати функцію з побічним ефектом». Але не «порахувати й повернути» (для цього є count, any, find і друзі).

Мініприклад «в один рядок сенсу»: почистити рядки, залишити непорожні, перетворити на довжини й порахувати, скільки з них мають довжину >= 3:

fun main() {
    val raw = listOf("  a  ", "   ", "kotlin", " go ")

    val count = raw
        .map { it.trim() }
        .filter { it.isNotEmpty() }
        .map { it.length }
        .count { it >= 3 }

    println(count) // 2
}

3. Навчальний приклад: витрати та команди без мутації

Щоб ланцюжки не «висіли в повітрі», продовжимо практичний консольний проєкт із тем про колекції та команди. Ми все ще «без ООП» (класи будуть пізніше), тож уявімо витрату як Triple:

  • first — сума,
  • second — категорія ("food", "taxi", "books"),
  • third — коментар.

Так, Triple — не вершина читабельності, але це чесний «місток» до класів. До речі, це чудовий привід писати маленькі функції-помічники, щоб код не був схожий на магічний ритуал із expense.first по всій програмі.

Дані витрат і функції-помічники

Зробімо кілька функцій, які поліпшують читабельність (і стануть у пригоді для посилань на функції через ::):

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

fun formatExpense(e: Triple<Int, String, String>): String =
    "${e.first} | ${e.second} | ${e.third}"

Зверніть увагу на стиль: кожна функція робить одну річ. Щойно функція починає «і нормалізувати, і перевіряти, і друкувати», це зазвичай сигнал, що ви намагаєтеся вмістити три різні кроки в один.

Довгий ланцюжок vs проміжні val

Майже в кожного ланцюжка є «точка зламу»: до неї все читається легко, а після — мозок починає вдавати, що йому терміново треба на обід. У Kotlin нормально й навіть корисно розривати довгі ланцюжки проміжними val із промовистими іменами. Це не «зайвий код», а підказки читачеві: «ось тут ми вже отримали очищені витрати», «ось тут залишили тільки категорію food».

Порівняймо підходи на прикладі: вивести всі витрати в категорії, яку ввів користувач. Категорію при цьому потрібно нормалізувати.

Варіант «усе в одному ланцюжку» (коротко, але може бути важко, якщо кроків стане більше):

fun printByCategory(
    expenses: List<Triple<Int, String, String>>,
    rawCategory: String
) {
    expenses
        .filter { it.second == rawCategory.trim().lowercase() }
        .map(::formatExpense)
        .forEach(::println)
}

Варіант із проміжними val (зазвичай читається спокійніше, особливо для новачків):

fun printByCategory(
    expenses: List<Triple<Int, String, String>>,
    rawCategory: String
) {
    val category = normalizeCategory(rawCategory)
    val filtered = expenses.filter { it.second == category }
    val lines = filtered.map(::formatExpense)

    lines.forEach(::println)
}

Тут ви прямо бачите: «ввели категорію → нормалізували → відфільтрували → відформатували → вивели». Це та сама структура «кроків», про яку ми говорили.

Команди: введення → нормалізація → результат

У консольних застосунках майже завжди є шар «сирого введення»: користувач вводить команду рядком, у ній бувають зайві пробіли, дивні регістри й узагалі повна свобода самовираження. Ланцюжки допомагають перетворити «сире введення» на нормальні дані так, щоб код не виглядав як локшина з if і тимчасових змінних.

Зробімо маленьку функцію: користувач вводить рядок із числами через пробіл, а ми хочемо отримати список Int, ігноруючи сміття. Це класичний випадок mapNotNull, адже toIntOrNull() повертає Int?, а нам потрібно відкинути null-результати.

fun parseInts(raw: String): List<Int> =
    raw.split(" ")
        .map { it.trim() }
        .filter { it.isNotEmpty() }
        .mapNotNull { it.toIntOrNull() }

Тут особливо важливо не переплутати: filterNotNull() прибирає саме null, а не порожні рядки. Порожні рядки прибираються окремим filter { it.isNotEmpty() }.

Тепер застосуймо ідею до нашого проєкту. Припустімо, у нас є команда "list food", яка виводить витрати за категорією. Ми хочемо:

  • не змінювати expenses,
  • отримати новий список рядків для друку,
  • вивести через forEach наприкінці.
fun listCommand(
    expenses: List<Triple<Int, String, String>>,
    rawCategory: String
) {
    val category = normalizeCategory(rawCategory)

    expenses
        .filter { it.second == category }
        .map(::formatExpense)
        .forEach(::println)
}

Зверніть увагу: жодного add, жодного remove, жодної мутації. Команда list має бути «чистою» у хорошому сенсі: вона лише показує.

4. Підсумкові операції: find, any, all, count

Дуже поширена помилка новачків: вони роблять filter, отримують список, а потім знову йдуть циклом і вручну рахують або шукають. Для старту це нормально (так ви чесно закріплюєте цикли), але тепер у нас є операції, які прямо виражають намір: «знайди», «перевір», «порахуй». Коли код «говорить» людською мовою, він рідше ламається випадково.

find: знайти першу придатну витрату

У реальному застосунку часто хочеться: «покажи першу покупку більшу за 5000» або «знайди перший запис категорії "taxi"». find повертає T?, і це не примха, а чесність: елемента може не бути.

fun printFirstOver(
    expenses: List<Triple<Int, String, String>>,
    threshold: Int
) {
    val found = expenses.find { it.first > threshold }

    val message = found?.let(::formatExpense) ?: "НЕ_ЗНАЙДЕНО"
    println(message)
}

Тут важливий стиль: ми не робимо found!!. Натомість явно описуємо поведінку «якщо не знайшли» через Elvis ?:.

any і all: перевірки

Перевірки — це місце, де ланцюжки особливо доречні: замість ручного прапорця var ok = false ми пишемо саме те, що маємо на увазі.

Наприклад, перевірмо: чи є хоч одна витрата з підозріло великим значенням (припустімо, більша за 100_000):

fun hasHugeExpense(expenses: List<Triple<Int, String, String>>): Boolean =
    expenses.any { it.first > 100_000 }

А тепер приклад all: чи всі витрати невідʼємні. Так, це звучить як «перевірка на здоровий глузд», але в користувацькому введенні здоровий глузд — гість нечастий.

fun allNonNegative(expenses: List<Triple<Int, String, String>>): Boolean =
    expenses.all { it.first >= 0 }

Окремо нагадаю важливу поведінку: all { ... } на порожній колекції повертає true (бо немає жодного контрприкладу). Це нормально — просто варто памʼятати про це під час інтерпретації.

count: скільки витрат підходить

Якщо вам потрібне число, то count зазвичай виразніший, ніж filter {...}.size, бо він прямо каже: «порахуй за умовою».

fun countInCategory(
    expenses: List<Triple<Int, String, String>>,
    rawCategory: String
): Int {
    val category = normalizeCategory(rawCategory)
    return expenses.count { it.second == category }
}

5. Обчислення окремо, друк окремо

Коли код маленький, дуже хочеться зробити println прямо всередині map або filter: «ну а що такого, працює ж». Працює — аж поки ви не захочете повторно використати обчислення без друку, протестувати його або просто зрозуміти, чому виведення дублюється. Тому корисна звичка така: спочатку ви будуєте результат як значення (List<String>, Int, Boolean), а вже потім виконуєте дію (println, forEach).

Порівняймо на простому прикладі.

Погано (змішали перетворення та друк):

fun printBad(expenses: List<Triple<Int, String, String>>) {
    expenses.map {
        println(it) // побічний ефект у map — читачеві важко
        it.first
    }
}

Краще (спочатку отримали рядки, потім вивели):

fun printGood(expenses: List<Triple<Int, String, String>>) {
    val lines = expenses.map(::formatExpense)
    lines.forEach(::println)
}

forEach наприкінці ланцюжка — це як поставити крапку в реченні. Ви вже все порахували, а тепер просто «використовуєте» результат.

6. Мінісценарій у main

Щоб побачити стиль цілком, зберімо маленький фрагмент main. Він не претендує на повний розбірник команд (він був раніше), зате показує, як ланцюжки працюють у реальному потоці: отримали дані → зробили перевірку → вивели список → порахували.

fun main() {
    val expenses = listOf(
        Triple(1200, "food", "coffee"),
        Triple(8000, "taxi", "airport"),
        Triple(300, "food", "bread")
    )

    println(hasHugeExpense(expenses)) // false
    println(allNonNegative(expenses)) // true

    listCommand(expenses, " FOOD ")
    // 1200 | food | coffee
    // 300 | food | bread

    println(countInCategory(expenses, "food")) // 2
}

Тут добре видно головне: список expenses не змінюється. Ми лише «знімаємо з нього зрізи» й будуємо результати.

7. Типові помилки під час побудови ланцюжків

Помилка №1: перетворювати forEach на «універсальну лопату» для всього.
Новачки часто роблять так: усередині forEach і фільтрують, і форматують, і рахують, і оновлюють зовнішні змінні. Це працює, але код стає складно перевіряти й важко змінювати: будь-яка правка додає ще один побічний ефект. Гарне правило таке: усе, що повертає значення (map/filter/find/any/all/count), робимо до forEach, а forEach залишаємо як фінальний крок — «використати результат».

Помилка №2: друкувати всередині map або filter і отримувати «магічне» виведення.
map і filter читаються як перетворення даних. Коли всередині них раптом зʼявляється println, у читача ламається очікування: він думає, що будує новий список, а насправді ще й запускає побічні ефекти. Це особливо небезпечно, коли ланцюжок повторно використають в іншому місці: там «раптом» почнеться друк, хоча ви цього не планували.

Помилка №3: писати ланцюжок на 12 викликів і пишатися, що він «в один рядок».
Компактність не дорівнює читабельності. Якщо ланцюжок довгий, розбийте його проміжними val із нормальними іменами. Ви не програєте в якості коду — навпаки, зробите його дружнім до майбутніх себе, які відкриють проєкт за тиждень і не згадають, що саме робив it на шостому кроці.

Помилка №4: забувати, де зʼявляється null, і намагатися звертатися до результату як до non-null.
find повертає T?, toIntOrNull() повертає Int?, і це не «перешкода», а сигнал: можливий сценарій «не знайшли / не розпарсилося». Якщо ви ігноруєте це й тягнетеся до !!, то ви не «спрощуєте код», а відкладаєте падіння програми на момент, коли користувач введе щось несподіване. Найчастіше краще застосувати ?: або заздалегідь очистити дані через mapNotNull.

Помилка №5: плутати filterNotNull() із фільтрацією порожніх рядків.
filterNotNull() викидає лише null-елементи й водночас покращує тип результату до non-null, що дуже зручно. Але порожній рядок "" — не null, і він нікуди не зникне. Тому для рядків зазвичай потрібен дует: спочатку map { it.trim() }, потім filter { it.isNotEmpty() }, а filterNotNull() застосовний лише тоді, коли у вас справді List<String?>.

1
Опитування
Операції з колекціями, рівень 22, лекція 4
Недоступний
Операції з колекціями
Операції з колекціями
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ