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("before toList")          // before toList
    val ys = seq.toList()
    println("after toList: $ys")      // after toList: [20, 40]
}

Здесь важен порядок вывода. Сначала вы увидите "before toList", и только потом начнут печататься "filter: ..." и "map: ...", потому что до toList() цепочка была только «описана».

А теперь — тот самый «больной» пример, где терминальная операция отсутствует:

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

    xs.asSequence()
        .filter { x ->
            println("check: $x")
            x > 1
        }

    println("done") // done
}

Вывод будет только "done". Строки "check: ..." не появятся вообще, потому что вы создали 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("result: $firstBigEven") // result: 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() (или другой терминальный шаг) и работайте с итогом.

1
Задача
Kotlin SELF, 25 уровень, 2 лекция
Недоступна
Фильтр бонусов
Фильтр бонусов
1
Задача
Kotlin SELF, 25 уровень, 2 лекция
Недоступна
Ленивый конвейер
Ленивый конвейер
1
Задача
Kotlin SELF, 25 уровень, 2 лекция
Недоступна
Быстрый поиск
Быстрый поиск
1
Задача
Kotlin SELF, 25 уровень, 2 лекция
Недоступна
Чистка расходов
Чистка расходов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ