369 lines
18 KiB
Markdown
369 lines
18 KiB
Markdown
---
|
||
title: Sequence
|
||
description: Ленивые вычисления, порядок обхода элементов и когда Sequence выигрывает у List
|
||
tags:
|
||
- kotlin
|
||
- collections
|
||
---
|
||
|
||
`Sequence<T>` - ленивый аналог коллекции. Операции не выполняются до запроса результата, и элементы проходят всю цепочку по одному.
|
||
|
||
```kotlin
|
||
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`:
|
||
|
||
```mermaid
|
||
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` |
|
||
|---|---|---|
|
||
| порядок | операция за операцией | элемент за элементом |
|
||
| промежуточные коллекции | на каждом шаге | нет |
|
||
| вычисление | сразу | при терминальной операции |
|
||
| можно ли прервать | нет | да |
|
||
| бесконечные данные | нет | да |
|
||
|
||
## Создание
|
||
```kotlin
|
||
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`, внутри него можно писать любую логику, включая циклы.
|
||
|
||
## Промежуточные и терминальные операции
|
||
```kotlin
|
||
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` не умеет в принципе:
|
||
```kotlin
|
||
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 в цепочке с ранним выходом
|
||
```kotlin
|
||
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 не выигрывает
|
||
```kotlin
|
||
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` |
|
||
|
||
## Операции, которые ломают ленивость
|
||
Некоторые операции обязаны прочитать всё, прежде чем выдать первый элемент:
|
||
```kotlin
|
||
fun main() {
|
||
val seq = sequenceOf(3, 1, 2)
|
||
.onEach { println("читаю $it") }
|
||
.sorted() // нужны все элементы
|
||
println("до first ничего не прочитано")
|
||
println(seq.first()) // здесь прочитаются все три
|
||
}
|
||
```
|
||
Такие операции - `sorted`, `sortedBy`, `distinct` (частично), `groupBy`, `chunked`. Ставьте их как можно позже в цепочке, а `filter` - как можно раньше.
|
||
|
||
## Sequence одноразов
|
||
```kotlin
|
||
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` при повторной попытке. Тот же механизм доступен явно:
|
||
```kotlin
|
||
fun main() {
|
||
val seq = sequenceOf(1, 2).constrainOnce()
|
||
println(seq.toList()) // [1, 2]
|
||
try {
|
||
seq.toList()
|
||
} catch (e: IllegalStateException) {
|
||
println("повторный обход запрещён")
|
||
}
|
||
}
|
||
```
|
||
|
||
## Чтение файла
|
||
Главный практический случай: данные, которые не влезают в память.
|
||
```kotlin
|
||
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](../Корутины/Flow.md)
|
||
|
||
> [!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-стримы напрямую (они доступны), либо корутины:
|
||
> ```kotlin
|
||
> 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 нет.** Он реализован на том же механизме приостановки, что и корутины, поэтому внутри можно писать обычные циклы и условия:
|
||
> ```kotlin
|
||
> 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](../../Программирование%20на%20Java/Коллекции/HashMap.md)
|
||
|
||
> [!warning] Подводные камни
|
||
> **Без терминальной операции не произойдёт ничего:**
|
||
> ```kotlin
|
||
> fun main() {
|
||
> listOf(1, 2, 3).asSequence().map { println("это не выведется"); it }
|
||
> println("конец") // только "конец"
|
||
> }
|
||
> ```
|
||
> Это частая ошибка при замене `forEach` на цепочку: если забыть терминальную операцию, код просто не выполнится, и компилятор не предупредит.
|
||
>
|
||
> **`Sequence` на маленьких коллекциях медленнее.** Обёртки и вызовы через итератор стоят дороже прямого цикла. Для списка из 10 элементов `asSequence()` - пессимизация.
|
||
>
|
||
> **Терминальная операция на бесконечной последовательности зависает:**
|
||
> ```kotlin
|
||
> 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`.
|
||
>
|
||
> **Последовательность из итератора обходится один раз** - второй вызов падает:
|
||
> ```kotlin
|
||
> fun main() {
|
||
> val seq = listOf(1, 2).iterator().asSequence()
|
||
> println(seq.count()) // 2
|
||
> try {
|
||
> seq.count()
|
||
> } catch (e: IllegalStateException) {
|
||
> println("повторный обход: ${e.message}")
|
||
> }
|
||
> }
|
||
> ```
|
||
> Исключение здесь лучше молчаливого пустого результата, но возникает оно в рантайме и только на втором обходе - в редкой ветке кода его легко не заметить. Если последовательность может понадобиться дважды, сохраните `toList()`.
|
||
>
|
||
> **Побочные эффекты в промежуточных операциях выполняются в неожиданном порядке:**
|
||
> ```kotlin
|
||
> 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` сломается.
|
||
>
|
||
> **Повторный обход пересчитывает всё заново:**
|
||
> ```kotlin
|
||
> 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` наружу нельзя - она окажется на закрытом ресурсе:
|
||
> ```kotlin
|
||
> 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() }` или сразу агрегат.
|
||
|
||
Далее: [Коллекции под капотом](Коллекции%20под%20капотом.md)
|