JavaRush /Курси /Kotlin SELF /Sequence: лінивість і термінальні операції

Sequence: лінивість і термінальні операції

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

1. Навіщо потрібен Sequence

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

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

Що таке Sequence і як його отримати

Якщо колекція List — це як готова тарілка пасти (її можна одразу їсти, перераховувати макаронини, сортувати макаронини, сперечатися з друзями, що макаронини не відсортовані), то Sequence — радше рецепт: «візьми інгредієнти, проціди, поріж, обсмаж…». Рецепт сам по собі не насичує. Насичує момент, коли ви починаєте готувати.

З погляду Kotlin, Sequence<T> — це інтерфейс, який можна перебирати (через Iterator). Тобто Sequence підходить для «проходу зліва направо», але не обіцяє зручного випадкового доступу за індексом (це не список).

Найважливіше правило дня звучить так:

Sequence лінивий: проміжні кроки не виконуються одразу. Вони спрацьовують лише тоді, коли зʼявляється термінальна операція, яка просить результат.

Як перевести колекцію в лінивий режим через asSequence()

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

fun main() {
    val xs = listOf(1, 2, 3, 4)

    val ys = xs.asSequence()
        .filter { it % 2 == 0 }
        .map { it * 10 }
        .toList()

    println(ys) // [20, 40]
}

Зверніть увагу на toList() наприкінці: це не «косметика», а момент, коли ви кажете: «Гаразд, Kotlin, тепер справді порахуй і дай мені звичайний список».

У документації й прикладах Kotlin зазвичай показують саме цей підхід: ланцюжок на Sequence, а потім toList(), щоб отримати кінцеву колекцію.

3. Проміжні та термінальні операції

Зараз буде важлива думка — вона заощаджує години налагодження й кілька нервових клітин (вони в програміста, як відомо, теж immutable).

Проміжні операції — це ті, що знову повертають Sequence і продовжують будувати «конвеєр»: map, filter, distinct, sorted (так, у послідовностей є варіанти), take, drop та багато інших. Вони не зобовʼязані виконувати обчислення просто зараз — вони лише додають ще один «етап конвеєра».

Термінальні операції — це ті, що просять результат: toList(), toSet(), count(), first() / firstOrNull(), find(), fold(), sum() (у різних формах) тощо. Термінальна операція запускає виконання: елементи починають проходити через конвеєр доти, доки результат не буде отримано.

Для зручності зведемо це в таблицю. Вона не охоплює всю стандартну бібліотеку, але дає правильну картину.

Операція Зазвичай на Sequence це… Що повертає Запускає обчислення?
map { ... }
проміжна
Sequence<R>
ні
filter { ... }
проміжна
Sequence<T>
ні
take(n)
проміжна
Sequence<T>
ні
toList()
термінальна
List<T>
так
count()
термінальна
Int
так
firstOrNull()
термінальна
T?
так
fold(initial) { ... }
термінальна
R
так

Якщо тримати в голові саме цю таблицю, то більшість загадок навколо Sequence зникає.

4. Чому «нічого не сталося»

Це класична ситуація: студент будує ланцюжок, очікує, що всередині filter або map щось виконається вже зараз, запускає програму — і… тиша. Зʼявляється відчуття, ніби Kotlin проігнорував ваш код. Kotlin, звісно, уміє багато чого, але телепатії поки не додали: ви просто не запросили результат.

Подивімося на приклад, де ми явно друкуємо всередині filter і map, щоб «побачити виконання».

fun main() {
    val xs = listOf(1, 2, 3, 4)

    val seq = xs.asSequence()
        .filter { x ->
            println("filter: $x")
            x % 2 == 0
        }
        .map { x ->
            println("map: $x")
            x * 10
        }

    println("перед toList")          // перед toList
    val ys = seq.toList()
    println("після toList: $ys")      // після toList: [20, 40]
}

Тут важливий порядок виводу. Спочатку ви побачите "перед toList", і лише потім почнуть друкуватися "filter: ..." і "map: ...", тому що до toList() ланцюжок був лише «описаний».

А тепер — той самий «болючий» приклад, але без термінальної операції:

fun main() {
    val xs = listOf(1, 2, 3)

    xs.asSequence()
        .filter { x ->
            println("перевірка: $x")
            x > 1
        }

    println("готово") // готово
}

Вивід буде лише "готово". Рядки "перевірка: ..." не зʼявляться взагалі, тому що ви створили Sequence, додали проміжний крок filter, а потім… викинули все це в смітник. Формально ви побудували конвеєр і тут же «пішли додому», навіть не ввімкнувши верстат.

5. Ранні термінальні операції та матеріалізація

У цьому розділі важливо акуратно підвести думку: термінальна операція запускає виконання, але не завжди змушує обробляти все до кінця. Наприклад, toList() справді збирає всі елементи результату, а от firstOrNull() може зупинитися на першому відповідному елементі.

Це не «оптимізація заради оптимізації», а просто сенс операції: якщо я запитав «перший відповідний», навіщо мені далі перебирати все?

Подивімося на демонстрацію:

fun main() {
    val xs = listOf(1, 2, 3, 4, 5)

    val firstBigEven = xs.asSequence()
        .filter { x ->
            println("filter: $x")
            x % 2 == 0
        }
        .map { x ->
            println("map: $x")
            x * 10
        }
        .firstOrNull { value ->
            println("firstOrNull predicate: $value")
            value >= 30
        }

    println("результат: $firstBigEven") // результат: 40
}

Зверніть увагу: послідовність не зобовʼязана обробити всі елементи до 5. Вона перебиратиме їх доти, доки не знайде відповідь, яка влаштує firstOrNull { ... }.

toList() як матеріалізація

Тут корисно пояснити простими словами: toList() — це як кнопка «зварити й викласти на тарілку». Поки ви в Sequence, у вас є рецепт і конвеєр. Після toList() зʼявляється конкретний List: його можна читати за індексами, друкувати, сортувати звичайними способами, передавати туди, де очікують List, і загалом користуватися ним у звичний спосіб.

Приклад «типового» переходу туди-назад:

fun main() {
    val raw = listOf("  food:120  ", " taxi:250", "food:80")

    val cleaned = raw.asSequence()
        .map { it.trim() }
        .filter { it.isNotEmpty() }
        .toList()

    println(cleaned) // [food:120, taxi:250, food:80]
}

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

6. Sequence у застосунку BudgetBuddy

Зараз ми акуратно продовжимо наш навчальний консольний застосунок (умовно назвемо його BudgetBuddy): він зберігає витрати й уміє будувати звіти. Нагадаю ідею структури даних без ООП: витрати можна зберігати як Triple(category, amount, note). Це не «ідеально», але для поточного етапу курсу — цілком достатньо.

Почнемо з невеликої заготовки даних:

typealias Expense = Triple<String, Int, String>

fun main() {
    val expenses = listOf(
        Expense("food", 120, "lunch"),
        Expense("taxi", 250, "airport"),
        Expense("food", 80, "coffee"),
        Expense("books", 500, "kotlin"),
    )

    println(expenses.size) // 4
}

Приклад звіту за категорією "food"

Зробімо ланцюжок, який:

спочатку залишає лише "food", потім перетворює витрату на рядок звіту, а наприкінці збирає список рядків.

typealias Expense = Triple<String, Int, String>

fun main() {
    val expenses = listOf(
        Expense("food", 120, "lunch"),
        Expense("taxi", 250, "airport"),
        Expense("food", 80, "coffee"),
    )

    val lines = expenses.asSequence()
        .filter { (cat, _, _) -> cat == "food" }
        .map { (cat, amount, note) -> "$cat: $amount ($note)" }
        .toList()

    println(lines) // [food: 120 (lunch), food: 80 (coffee)]
}

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

«Я додав filter, але нічого не змінилося»

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

Ось приклад неправильного підходу (він компілюється й нічого не робить — прямо як дехто зі студентів у понеділок зранку):

typealias Expense = Triple<String, Int, String>

fun main() {
    val expenses = mutableListOf(
        Expense("food", 120, "lunch"),
        Expense("taxi", 250, "airport"),
    )

    expenses.asSequence()
        .filter { (cat, _, _) -> cat == "food" }

    println(expenses.size) // 2
}

Чому 2, а не 1? Тому що filter на Sequence не «видаляє зі списку» — він створює нову послідовність (рецепт відбору). Ви її не використали й не запросили результат.

Правильний варіант — отримати результат і явно його використати:

typealias Expense = Triple<String, Int, String>

fun main() {
    val expenses = listOf(
        Expense("food", 120, "lunch"),
        Expense("taxi", 250, "airport"),
    )

    val foods = expenses.asSequence()
        .filter { (cat, _, _) -> cat == "food" }
        .toList()

    println(foods.size) // 1
}

7. Sequence і побічні ефекти

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

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

Тому хороша звичка така: map/filter тримаємо чистими (без побічних ефектів), а якщо потрібно «подивитися, що відбувається», робимо це або тимчасово через println, або окремим кроком, який не впливає на сенс програми.

8. Типові помилки під час роботи з Sequence

Помилка № 1: немає термінальної операції — «нічого не сталося».
Це найпопулярніша пастка. Ви будуєте asSequence().filter { ... }.map { ... }, а потім чекаєте, що список уже «став іншим». Але Sequence — це лінивий рецепт, а не готовий результат. Потрібен термінальний крок: toList(), count(), firstOrNull(), fold() тощо.

Помилка № 2: очікування, що Sequence «змінює вихідну колекцію».
filter і map не видаляють елементи з вашого MutableList і не переписують його. Вони створюють новий результат (або новий «конвеєр»). Якщо потрібно справді видалити — це інша задача й інший код, а Sequence тут не чарівна мітла.

Помилка № 3: побічні ефекти всередині map/filter і подальша плутанина.
Коли всередині map ви щось друкуєте, збільшуєте лічильник, пишете у файл або змінюєте зовнішню змінну, ви привʼязуєте сенс програми до моменту обчислення. У лінивій моделі момент обчислення залежить від термінальної операції, і код стає складнішим для передбачення та налагодження.

Помилка № 4: перетворення Sequence на «релігію».
Іноді студент починає вставляти asSequence() у кожен ланцюжок, бо це виглядає «професійно». На невеликих даних це часто не дає виграшу, а інколи навіть погіршує читабельність: стає важче зрозуміти, де саме отримуємо результат. Набагато краще тримати в голові просте правило: Sequence корисний, коли вам важливе ліниве виконання ланцюжка; в інших випадках звичайні операції над List залишаються чудовим і читабельним інструментом.

Помилка № 5: забули, що результат потрібен як колекція, і не зробили матеріалізацію.
Дуже поширена історія у звітах: ви побудували Sequence<String>, а потім намагаєтеся друкувати її так, ніби це List<String>, або передати туди, де очікують список. Рішення просте: наприкінці додайте toList() (або інший термінальний крок) і працюйте з підсумком.

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