1. Навіщо писати ланцюжки, а не цикли
Якщо ви коли-небудь писали код на кшталт «створити порожній список → пройти циклом → у if додати → потім ще раз пройти циклом», то вам знайоме відчуття, коли поступово зʼявляється думка: програма — як каструля. Ми постійно щось помішуємо й боїмося розхлюпати. Ланцюжки викликів у Kotlin дають змогу описувати обробку даних як послідовність кроків. Кожен крок повертає результат, а «трубопровід» (pipeline) видно прямо в коді.
Ключова ідея цієї лекції проста: ми намагаємося не мутувати початкову колекцію, а будувати нові результати поверх неї. Тобто замість «взяли список витрат і видалили з нього все зайве» робимо «взяли список витрат і отримали новий список — лише те, що потрібно». Так код стає передбачуванішим: ви менше хвилюєтеся, що десь «там» список уже змінили, і налагодження не перетворюється на полювання на привидів.
Невеличка таблиця для наочності:
| Операція | Що повертає | Мутує початкову колекцію? | Типова роль |
|---|---|---|---|
|
|
ні | «залишити потрібне» |
|
|
ні | «перетворити на інше» |
|
|
ні | «знайти перше потрібне» |
|
|
ні | «швидка перевірка» |
|
|
ні | «скільки елементів підходить» |
|
|
ні | «виконати дію наприкінці» |
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?>.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ