Files
obsidian/Kotlin/Коллекции/Sequence.md
T

18 KiB
Raw Blame History

title, description, tags
title description tags
Sequence Ленивые вычисления, порядок обхода элементов и когда Sequence выигрывает у List
kotlin
collections

Sequence<T> - ленивый аналог коллекции. Операции не выполняются до запроса результата, и элементы проходят всю цепочку по одному.

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

    println("-- List:")
    val eager = list
        .map { println("  map $it"); it * 2 }
        .filter { println("  filter $it"); it > 4 }
        .first()
    println("  результат $eager")

    println("-- Sequence:")
    val lazy = list.asSequence()
        .map { println("  map $it"); it * 2 }
        .filter { println("  filter $it"); it > 4 }
        .first()
    println("  результат $lazy")
}

List выполнит 5 map и 5 filter - всего 10 операций, создав два промежуточных списка. Sequence выполнит 3 map и 3 filter и остановится, как только найдёт первый подходящий элемент.

Два порядка обхода

Это ключ к пониманию Sequence:

graph LR
    subgraph List["List: по операциям"]
        L1["map всех 5"] --> L2["filter всех 5"] --> L3["first"]
    end
    subgraph Seq["Sequence: по элементам"]
        S1["1: map, filter"] --> S2["2: map, filter"] --> S3["3: map, filter → стоп"]
    end
List Sequence
порядок операция за операцией элемент за элементом
промежуточные коллекции на каждом шаге нет
вычисление сразу при терминальной операции
можно ли прервать нет да
бесконечные данные нет да

Создание

fun main() {
    println(listOf(1, 2, 3).asSequence().toList())         // [1, 2, 3]
    println(sequenceOf(1, 2, 3).toList())                   // [1, 2, 3]
    println(emptySequence<Int>().toList())                  // []

    println(generateSequence(1) { it * 2 }.take(5).toList())        // [1, 2, 4, 8, 16]
    println(generateSequence(1) { if (it < 5) it + 1 else null }.toList())   // [1, 2, 3, 4, 5]

    val built = sequence {
        yield(1)
        yieldAll(listOf(2, 3))
        yield(4)
    }
    println(built.toList())                                  // [1, 2, 3, 4]
}

generateSequence с null в лямбде - способ задать конец. sequence { } - билдер с yield, внутри него можно писать любую логику, включая циклы.

Промежуточные и терминальные операции

fun main() {
    val seq = sequenceOf(1, 2, 3)

    // промежуточные: возвращают Sequence, ничего не считают
    val chain = seq.map { it * 2 }.filter { it > 2 }.take(2)
    println("цепочка построена, вычислений не было")

    // терминальные: запускают вычисление
    println(chain.toList())        // [4, 6]
    println(seq.sum())              // 6
    println(seq.count())            // 3
    println(seq.first())            // 1
    println(seq.any { it > 2 })     // true
}
Промежуточные Терминальные
map, filter, flatMap toList, toSet, toMap
take, drop, distinct first, last, single
sorted, chunked, windowed sum, count, average, max
onEach, mapNotNull any, all, none, forEach
zip, plus fold, reduce, joinToString

Бесконечные последовательности

То, чего List не умеет в принципе:

fun main() {
    val naturals = generateSequence(1) { it + 1 }

    println(naturals.take(5).toList())                       // [1, 2, 3, 4, 5]
    println(naturals.filter { it % 7 == 0 }.first())          // 7
    println(naturals.map { it * it }.takeWhile { it < 50 }.toList())   // [1, 4, 9, 16, 25, 36, 49]

    val fib = generateSequence(0 to 1) { (a, b) -> b to (a + b) }.map { it.first }
    println(fib.take(10).toList())      // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
}

Важно: терминальная операция без ограничения на бесконечной последовательности зависнет. naturals.toList() не завершится никогда.

Sequence в цепочке с ранним выходом

data class Record3(val id: Int, val active: Boolean, val score: Int)

fun main() {
    val records = List(1_000_000) { Record3(it, it % 100 == 0, it % 7) }

    val found = records.asSequence()
        .filter { it.active }
        .map { it.score * 2 }
        .firstOrNull { it > 10 }
    println(found)         // 12

    val top3 = records.asSequence()
        .filter { it.active }
        .take(3)
        .map { it.id }
        .toList()
    println(top3)          // [0, 100, 200]
}

Здесь Sequence обрабатывает несколько сотен элементов вместо миллиона. На List каждый filter/map прошёл бы весь миллион и создал два списка.

Когда Sequence не выигрывает

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

    // Sequence здесь только добавляет накладные расходы
    println(small.map { it * 2 }.sum())                   // 30
    println(small.asSequence().map { it * 2 }.sum())      // 30, но медленнее
}

Sequence создаёт объект-обёртку на каждую операцию и вызывает лямбды через интерфейс итератора. На маленьких коллекциях и коротких цепочках это дороже, чем прямой проход.

Практическое правило:

Условие Выбор
меньше ~1000 элементов, 1-2 операции List
много элементов и 3+ операций Sequence
есть first, find, take, any после цепочки Sequence
источник бесконечный или потоковый (файл, БД) Sequence
нужен результат целиком, операций мало List
нужен индексный доступ к результату List

Операции, которые ломают ленивость

Некоторые операции обязаны прочитать всё, прежде чем выдать первый элемент:

fun main() {
    val seq = sequenceOf(3, 1, 2)
        .onEach { println("читаю $it") }
        .sorted()                      // нужны все элементы
    println("до first ничего не прочитано")
    println(seq.first())               // здесь прочитаются все три
}

Такие операции - sorted, sortedBy, distinct (частично), groupBy, chunked. Ставьте их как можно позже в цепочке, а filter - как можно раньше.

Sequence одноразов

fun main() {
    val seq = listOf(1, 2, 3).asSequence().map { it * 2 }
    println(seq.toList())       // [2, 4, 6]
    println(seq.toList())       // [2, 4, 6] — здесь повезло: источник List

    val once = sequence { yield(1); yield(2) }
    println(once.toList())      // [1, 2]
    println(once.toList())      // [1, 2] — тоже работает, лямбда вызвана заново

    val fromIterator = listOf(1, 2, 3).iterator().asSequence()
    println(fromIterator.toList())   // [1, 2, 3]
    try {
        fromIterator.toList()
    } catch (e: IllegalStateException) {
        println(e.message)           // This sequence can be consumed only once.
    }
}

Последовательность из итератора обходится один раз: Iterator.asSequence() оборачивает результат в constrainOnce(), который фиксирует это ограничение и бросает IllegalStateException при повторной попытке. Тот же механизм доступен явно:

fun main() {
    val seq = sequenceOf(1, 2).constrainOnce()
    println(seq.toList())       // [1, 2]
    try {
        seq.toList()
    } catch (e: IllegalStateException) {
        println("повторный обход запрещён")
    }
}

Чтение файла

Главный практический случай: данные, которые не влезают в память.

import java.io.File

fun main() {
    val tmp = File.createTempFile("demo", ".txt")
    tmp.writeText((1..100).joinToString("\n"))

    val sum = tmp.useLines { lines ->          // Sequence<String>
        lines.map { it.toInt() }.filter { it % 2 == 0 }.sum()
    }
    println(sum)        // 2550

    val firstBig = tmp.useLines { lines -> lines.first { it.toInt() > 50 } }
    println(firstBig)   // 51 — файл прочитан до 51-й строки, не целиком

    tmp.delete()
}

useLines даёт Sequence<String> и закрывает файл после работы. Альтернатива readLines() загрузила бы весь файл в List<String>.

Sequence и Flow

Sequence - ленивый, но синхронный: получение следующего элемента блокирует поток. Для асинхронных источников нужен Flow:

Sequence Flow
вычисление лениво лениво
блокировка да нет, suspend
внутри можно delay нет да
источник память, файл, генератор сеть, БД, события

Подробнее: Flow

[!info] Отличия от Java

Java Stream Kotlin Sequence
list.stream() list.asSequence()
Stream.of(1, 2) sequenceOf(1, 2)
Stream.iterate(1, f) generateSequence(1, f)
collect(toList()) toList()
.parallel() нет аналога
IntStream, LongStream нет, только Sequence<T>
Files.lines(path) file.useLines { }
нет генератора с yield sequence { yield(x) }

Sequence и Stream - почти одно и то же. Ленивость, промежуточные и терминальные операции, короткое замыкание на findFirst/first - всё совпадает. Поэтому опыт работы со стримами переносится напрямую, и наоборот: если в Java вы написали бы .stream(), в Kotlin нужен .asSequence().

Главное отличие - нет параллелизма. Stream.parallel() распределяет работу по ForkJoinPool; у Sequence такого нет вообще. Причина в том, что для конкурентности в Kotlin предусмотрены корутины, а не параллельные стримы. Если нужна параллельная обработка - либо используйте Java-стримы напрямую (они доступны), либо корутины:

fun main() {
    // Java Stream доступен из Kotlin
    val sum = listOf(1, 2, 3).stream().parallel().mapToInt { it * 2 }.sum()
    println(sum)      // 12
}

Нет специализаций для примитивов. В Java IntStream избегает упаковки: list.stream().mapToInt(x -> x).sum() работает с int. Sequence<Int> упаковывает каждый элемент в Integer. На числовых расчётах по большим данным Java-стримы могут оказаться быстрее.

sequence { yield(x) } - генератор, которого в Java нет. Он реализован на том же механизме приостановки, что и корутины, поэтому внутри можно писать обычные циклы и условия:

fun main() {
    val primes = sequence {
        var n = 2
        while (true) {
            if ((2 until n).none { n % it == 0 }) yield(n)
            n++
        }
    }
    println(primes.take(5).toList())      // [2, 3, 5, 7, 11]
}

В Java это требует либо отдельного потока, либо ручной реализации Iterator с сохранением состояния.

Сравнить: HashMap в Java

[!warning] Подводные камни Без терминальной операции не произойдёт ничего:

fun main() {
    listOf(1, 2, 3).asSequence().map { println("это не выведется"); it }
    println("конец")       // только "конец"
}

Это частая ошибка при замене forEach на цепочку: если забыть терминальную операцию, код просто не выполнится, и компилятор не предупредит.

Sequence на маленьких коллекциях медленнее. Обёртки и вызовы через итератор стоят дороже прямого цикла. Для списка из 10 элементов asSequence() - пессимизация.

Терминальная операция на бесконечной последовательности зависает:

fun main() {
    val naturals = generateSequence(1) { it + 1 }
    println(naturals.take(3).toList())      // [1, 2, 3] — ок
    // naturals.toList()                     // зависнет навсегда
    // naturals.count()                      // тоже
    // naturals.filter { it > 10 }.toList()  // и это
}

Любая цепочка из бесконечного источника обязана содержать take, takeWhile, first или find.

sorted и groupBy убивают ленивость - они читают всё до первого результата. Цепочка asSequence().sorted().first() не быстрее, чем на List.

Последовательность из итератора обходится один раз - второй вызов падает:

fun main() {
    val seq = listOf(1, 2).iterator().asSequence()
    println(seq.count())      // 2
    try {
        seq.count()
    } catch (e: IllegalStateException) {
        println("повторный обход: ${e.message}")
    }
}

Исключение здесь лучше молчаливого пустого результата, но возникает оно в рантайме и только на втором обходе - в редкой ветке кода его легко не заметить. Если последовательность может понадобиться дважды, сохраните toList().

Побочные эффекты в промежуточных операциях выполняются в неожиданном порядке:

fun main() {
    val log = mutableListOf<String>()
    sequenceOf(1, 2)
        .onEach { log.add("A$it") }
        .onEach { log.add("B$it") }
        .toList()
    println(log)       // [A1, B1, A2, B2] — не [A1, A2, B1, B2]
}

На List вывод был бы [A1, A2, B1, B2]. Код, зависящий от порядка побочных эффектов, при переходе на Sequence сломается.

Повторный обход пересчитывает всё заново:

fun main() {
    var calls = 0
    val seq = listOf(1, 2, 3).asSequence().map { calls++; it * 2 }
    seq.toList()
    seq.toList()
    println(calls)      // 6 — лямбда вызвана дважды для каждого элемента
}

Если результат нужен несколько раз, сохраните его в List.

Sequence синхронна. Внутри её лямбд нельзя вызывать suspend-функции - для этого существует Flow. Попытка использовать Sequence для сетевых запросов приведёт к блокировке потока.

useLines закрывает файл по выходу из лямбды. Вернуть Sequence наружу нельзя - она окажется на закрытом ресурсе:

import java.io.File

fun main() {
    val tmp = File.createTempFile("leak", ".txt")
    tmp.writeText("1\n2")
    val leaked = tmp.useLines { it }           // Sequence на закрытом файле
    try {
        println(leaked.toList())
    } catch (e: java.io.IOException) {
        println("поток закрыт: ${e.message}")   // поток закрыт: Stream closed
    }
    tmp.delete()
}

Внутри лямбды нужно получить готовое значение: useLines { it.toList() } или сразу агрегат.

Далее: Коллекции под капотом