18 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Sequence | Ленивые вычисления, порядок обхода элементов и когда Sequence выигрывает у List |
|
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 { }нет генератора с yieldsequence { 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() }или сразу агрегат.
Далее: Коллекции под капотом