1. Вступ
Коли ви пишете код для колекцій, дуже легко потрапити в приємну пастку. Ланцюжок виглядає гарно, читається як текст, і хочеться вірити, що він «і працює так само гарно». Часто так і є. Але іноді за одним рядком ховається кілька повних проходів по даних і купа тимчасових списків, які створюються та одразу викидаються.
Це не «мікрооптимізація заради галочки», а нормальна інженерна звичка: розуміти, скільки роботи насправді робить ваш код. Особливо якщо даних стане у 100 разів більше.
Уявіть, що в нас не 20 витрат, а 200_000, і користувач просить звіт «топ‑10 категорій». Якщо ваш код робить чотири проходи й будує три проміжні списки, він усе одно може бути коректним. Просто витрачатиме зайвий час і памʼять.
Ми не будемо вимірювати мілісекунди й будувати графіки профайлера — це окрема тема. Натомість навчимося читати пайплайн «за вартістю», як кошторис ремонту: «тут зайвий шар штукатурки».
2. Вартість пайплайна: проходи, колекції й обʼєкти
Коли ви бачите ланцюжок на кшталт filter().map().filter().map(), у нього є своя ціна. Зазвичай вона складається з трьох частин: скільки разів ви пробіглися вихідними даними, скільки проміжних колекцій створили між кроками та скільки тимчасових обʼєктів наробили дорогою (наприклад, нових рядків, Pair, Triple тощо). Ця модель корисна навіть без точних цифр: ви починаєте помічати «зайві рухи» в коді.
Давайте зафіксуємо це в невеликій таблиці — без фанатизму, лише щоб було зручніше орієнтуватися:
| Що відбувається | Як проявляється в коді | Чим може відгукнутися |
|---|---|---|
| Зайві проходи по даних | кілька кроків, і кожен — «знову по всіх» | зайвий час CPU |
| Проміжні колекції | ланцюжок на List часто матеріалізує результат кожного кроку | зайва памʼять + збирання сміття |
| Тимчасові обʼєкти | часто створюєте нові елементи (наприклад, map { ... }) | навантаження на GC, особливо за великих обсягів |
Важливо: іноді читабельність важливіша, і ви свідомо лишаєте «два зрозумілі кроки» замість одного хитрішого. Але це має бути усвідомлений вибір, а не випадковість.
Як подумки «оцінювати» пайплайн за виконанням
Корисно тримати в голові просту картинку: уявіть пайплайн як конвеєр.
flowchart TD
A[List: вихідні дані] --> B[filter]
B --> C[map]
C --> D[filter]
D --> E[toList / fold / count]
Коли це List, багато кроків (умовно B, C, D) часто створюють проміжні списки: результат filter — список, результат map — список, і так далі.
Коли це Sequence, кроки B, C, D зазвичай не матеріалізуються, доки не виконається термінальна операція E. Тоді елементи йдуть «по одному» через увесь конвеєр. Ідея «проміжні vs термінальні» та «лінивість» — ключ до розуміння, чому Sequence може зменшити кількість проміжних колекцій.
3. Зайві проходи й проміжні колекції
Зайві проходи: де вони зʼявляються «за звичкою»
Дуже типовий шаблон у новачків (і, чесно кажучи, у досвідчених розробників, коли вони втомилися) виглядає так: «спочатку відфільтрую», а потім «окремо порахую». Це нормально… доки даних небагато. Але корисно розуміти, що це два проходи: один для filter, другий — для fold/sum.
Припустімо, у нас є витрати у вигляді Triple(id, category, amount). Поки без ООП — ми в тій частині курсу, де робимо проєкт «на функціях і колекціях».
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "taxi", 250),
Triple(3, "food", 80),
)
val foodOnly = expenses.filter { it.second == "food" }
val totalFood = foodOnly.fold(0) { acc, e -> acc + e.third }
println(totalFood) // 200
}
Код читається ідеально: спочатку лишили тільки їжу, потім склали суми. Але модель виконання така: ви пройшлися по expenses, побудували новий список foodOnly, потім пройшлися по foodOnly ще раз і порахували суму.
Сам fold — це стандартний спосіб «накопичити підсумок»: він послідовно комбінує акумулятор з елементами.
Чи можна зробити один прохід? Так. Чи завжди це потрібно? Ні. Але вміти — корисно.
Проміжні колекції: що відбувається в ланцюжку на List
Коли ви викликаєте операції на кшталт filter і map на звичайній колекції (List), ви зазвичай отримуєте новий список як результат кожного кроку. Тобто ланцюжок:
val result = xs.filter { ... }.map { ... }.filter { ... }
у концептуальній моделі робить приблизно так:
- побудував список після першого filter
- побудував список після map
- побудував список після другого filter
І лише потім ви побачили підсумок.
Це не «погано» і не «помилка в Kotlin». Навпаки: такий контракт робить код передбачуваним, тому що кожен крок дає вам готову колекцію. Але якщо кроків багато, а даних багато, ви починаєте платити за кожен проміжний список.
4. Sequence: коли допомагає і коли заважає
Де Sequence реально допомагає
Sequence корисний не тим, що він «магічно завжди швидший», а тим, що змінює модель виконання: операції стають лінивими, і елементи можуть проходити через ланцюжок «потоком», без проміжних списків — доти, доки ви не попросите результат термінальною операцією.
Особливо сильний ефект зʼявляється, коли в нас є «раннє завершення»: наприклад, take(n) або пошук першого відповідного елемента. Сенс такий: якщо вам потрібно лише 5 елементів, не треба обробляти 100_000.
Щоб побачити ідею, зробімо приклад не про витрати, а про числа (так простіше сприймати очима). Сам прийом — той самий.
fun main() {
val xs = (1..1_000).toList()
val firstFive = xs.asSequence()
.map { it * it }
.filter { it % 10 == 0 }
.take(5)
.toList()
println(firstFive) // [100, 400, 900, 1600, 2500]
}
Тут важливо, що take(5) — операція «взяти перші N». У лінивій моделі це часто означає, що ви не оброблятимете «хвіст», який усе одно не потрапить у результат.
Якби ви робили те саме на List без Sequence, то найімовірніше спочатку побудували б список квадратів для всіх 1 000 чисел, потім відфільтрували, а вже тоді взяли 5. На 1 000 елементах різниці майже немає, а на 10_000_000 — вона може бути вже дуже помітною.
Де Sequence може заважати
Sequence — інструмент, а не релігія. Іноді він ускладнює життя, тому що лінивість додає «часовий фактор»: не завжди очевидно, коли саме виконається код усередині map/filter.
Є й нюанс, який часто дивує новачків: Sequence можна випадково «пройти» кілька разів — і тоді робота буде виконана кілька разів.
Подивімося на приклад. Ми зробимо ланцюжок і двічі запросимо результат різними термінальними операціями.
fun main() {
val xs = listOf(1, 2, 3, 4)
val seq = xs.asSequence()
.map { x ->
println("map $x")
x * 10
}
println(seq.count()) // map 1..4 буде надруковано, count = 4
println(seq.toList()) // map 1..4 буде надруковано ЩЕ РАЗ
}
Вивід буде приблизно такий (спрощено):
map 1
map 2
map 3
map 4
4
map 1
map 2
map 3
map 4
[10, 20, 30, 40]
І це логічно: Sequence — це не «готовий результат», а «опис того, як отримати елементи». Тому якщо ви двічі попросили результат, то елементи двічі й порахуються.
На звичайній List ви б один раз побудували новий список, а далі перевикористовували б його. Тож іноді Sequence не прискорює, а навпаки додає роботи, якщо ви постійно запитуєте результат кілька разів.
Практичне правило таке: якщо ви зробили Sequence і хочете використати підсумок більш ніж один раз, часто має сенс матеріалізувати його в List один раз через toList(), а далі працювати вже з ним.
Побічні ефекти як підсилювач проблем
Є ще одна причина, чому Sequence іноді «заважає»: побічні ефекти (наприклад, println) усередині map/filter привʼязуються до моменту виконання, а він відкладається до термінальної операції. Ви вже бачили цю ідею в минулій лекції як «чому нічого не сталося».
Сьогодні нам важливо інше: побічні ефекти роблять пайплайн складнішим для розуміння й іноді ведуть до повторної роботи (як у прикладі вище). А повторна робота = повторні побічні ефекти. Якщо ви всередині map пишете лог, то він полетить стільки разів, скільки ви запросили термінальний результат. Іноді це нормально. А іноді — «чому в мене 2 000 однакових рядків у логах».
У навчальних прикладах ми інколи використовуємо println усередині ланцюжків спеціально — як підсвітку. Але в реальному коді краще тримати перетворення «чистими», а ефекти — окремо. Це особливо важливо, коли ви переходите до лінивих обчислень.
5. Практика: звіт за витратами без зайвої роботи
Тепер давайте акуратно привʼяжемо все до нашого практичного консольного проєкту (умовно назвемо його BudgetCli). Нагадаю формат даних: список Triple(id, category, amount). Наша мета — зробити звіт «топ‑N категорій за сумою».
Читабельний варіант звіту
Спочатку напишемо максимально зрозумілий варіант: зібрати суми за категоріями, перетворити це на список, відсортувати й узяти take(n).
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "taxi", 250),
Triple(3, "food", 80),
Triple(4, "books", 300),
)
val totals = expenses.fold(mutableMapOf<String, Int>()) { acc, e ->
acc[e.second] = (acc[e.second] ?: 0) + e.third
acc
}
val top2 = totals.entries
.sortedByDescending { it.value }
.take(2)
println(top2) // [books=300, food=200]
}
Тут fold робить зрозумілу річ: накопичує структуру (MutableMap) по одному елементу. Важливо, що акумулятором у fold може бути не лише число — для агрегацій це цілком нормальна ідея.
Але далі є момент: sortedByDescending повертає новий список (тобто матеріалізує результат сортування), і лише потім take(2) бере перші два. Для малої кількості категорій це не страшно. Але якщо категорій дуже багато, сортувати весь список заради top‑2 — надто дорого.
Ми поки не будемо вивчати «просунуті структури» й алгоритми вибору top‑N без повного сортування (це інший пласт тем). Але можемо хоча б чітко розуміти: сортування всього — це робота.
Де Sequence у проєкті допомагає, а де ні
У цьому місці багато хто автоматично каже: «О, давайте asSequence()». І саме тут починається доросла інженерія: треба спитати себе — а що саме стане краще?
Якщо у вас:
- мало даних (десятки/сотні),
- ланцюжок короткий (2–3 кроки),
- результат потрібен повністю,
то Sequence часто не дає виграшу, а іноді робить код трохи менш очевидним.
Але якщо у вас:
- ланцюжок довгий,
- є потенційне раннє завершення,
- багато проміжних кроків (особливо map/filter/map/filter/...),
то Sequence може зменшити кількість проміжних колекцій.
Наприклад, якщо ви хочете взяти лише перші кілька «підозрілих» операцій (скажімо, витрати більші за 500), то Sequence + take(n) — хороший кандидат.
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "taxi", 250),
Triple(3, "books", 900),
Triple(4, "taxi", 700),
Triple(5, "food", 50),
)
val bigOnes = expenses.asSequence()
.filter { it.third >= 500 }
.take(2)
.toList()
println(bigOnes) // [(3, books, 900), (4, taxi, 700)]
}
Тут take(2) дуже важливий: він дає шанс не обробляти «хвіст» списку, якщо перші два відповідні елементи знайшлися рано. Сама ідея take — «обмежити кількість елементів», і вона добре лягає на ліниву модель.
«Склеювання кроків» замість зайвих колекцій
Іноді найкращий спосіб прискорити пайплайн — не лінивість, а зменшити кількість кроків. Наприклад, замість:
filter → map → filterNotNull
можна використати одну операцію mapNotNull. Ви це вже проходили в лекціях про очищення даних і зміну типів.
У нашому проєкті це корисно під час розбору вхідних рядків. Припустімо, у вас є сирі команди, і ви хочете витягти лише суми (як Int), пропускаючи сміття.
fun main() {
val rawAmounts = listOf("120", " ", "oops", "250")
val amounts = rawAmounts.mapNotNull { it.trim().toIntOrNull() }
println(amounts) // [120, 250]
}
Тут ви зробили один прохід і одразу отримали «чистий» List<Int>. Якби ви зробили map { ... } із поверненням Int?, а потім filterNotNull, було б два кроки. Знову ж таки: іноді два кроки читабельніші — і це нормально. Але тепер ви принаймні розумієте ціну.
6. Практичне правило вибору: List чи Sequence
Важливо не перетворити курс на культ Sequence, тож зафіксуймо здорову інтуїцію. Якщо ваш ланцюжок короткий і зрозумілий, а обсяг даних невеликий, то звичайні операції на List — чудовий вибір: читабельно, передбачувано, легко налагоджувати. Kotlin-стандартна бібліотека взагалі розрахована на те, що ви активно використовуєте map/filter/reduce/fold і не боїтеся цього.
Якщо ланцюжок довгий, даних багато, і особливо якщо є take(n) або пошук «першого відповідного», то Sequence стає корисним кандидатом. Він може прибрати проміжні колекції й зупинитися раніше.
А якщо ви плануєте кілька разів використати результат, то Sequence іноді шкідливий: ви можете випадково порахувати одне й те саме кілька разів. Тоді краще матеріалізувати в List один раз (через toList()) і далі працювати вже з готовими даними.
7. Типові помилки під час спроб «оптимізувати пайплайн»
Помилка № 1: оптимізація без розуміння, що саме ви оптимізуєте.
Дуже легко замінити list.filter { ... }.map { ... } на list.asSequence().filter { ... }.map { ... }.toList() і гордо вважати, що стало швидше. Але якщо даних мало й ланцюжок короткий, ви могли нічого не виграти, а код став трохи менш прозорим. Краще спочатку навчитися бачити зайві кроки й проміжні списки, а вже потім обирати інструмент.
Помилка № 2: повторне використання Sequence так, ніби це готовий результат.
Sequence — це «рецепт», а не «готова страва». Якщо ви виконали термінальну операцію двічі (наприклад, count(), потім toList()), то рецепт «приготується» двічі. Це особливо болісно, якщо всередині пайплайна є важкі обчислення або побічні ефекти (println, логування).
Помилка № 3: побічні ефекти всередині map/filter і здивування через «дивний час виконання».
Коли код лінивий, побічні ефекти відбуваються не там, де ви їх «бачите очима», а там, де сталася термінальна операція. У результаті ви можете отримати «пізні» логи, дублювання логів або відчуття, що програма «нічого не робить». Для навчальних цілей println усередині ланцюжка корисний, але в робочому стилі краще тримати перетворення максимально чистими.
Помилка № 4: сліпе сортування «всього» заради top‑N.
sortedByDescending(...).take(n) — зручний і читабельний патерн, ви його вже використовували. Але важливо розуміти: сортується весь список цілком, а вже потім ви берете перші елементи. Іноді це нормально, а іноді — найважче місце у звіті. Сьогодні ми не вивчаємо альтернативні алгоритми вибору top‑N без повного сортування, але сам факт «тут сортування всього» ви маєте навчитися помічати.
Помилка № 5: спроба розвʼязати проблему проміжних колекцій там, де проблема — зайві кроки.
Іноді ви робите filter, а потім ще раз filter за майже тією самою умовою. Або спочатку map, а потім map знову, хоча можна було зробити один крок. У таких місцях Sequence може зменшити кількість проміжних списків, але не зменшить кількість логічних кроків. Часто сильніше поліпшення — просто спростити пайплайн: наприклад, замінити map { ... }.filterNotNull() на mapNotNull { ... }.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ