1. Введение
Когда мы пишем код на коллекциях, очень легко попасть в приятную ловушку: цепочка выглядит красиво, читается как текст, и хочется верить, что она «и работает красиво». Часто так и есть. Но иногда за одной строкой прячется несколько полных проходов по данным и пачка временных списков, которые создаются и тут же выкидываются.
Это не «микрооптимизация ради галочки», а нормальная инженерная привычка: понимать, сколько работы реально делает ваш код, особенно если данных станет в 100 раз больше.
Представьте, что у нас не 20 расходов, а 200_000, и пользователь просит отчёт «топ‑10 категорий». Если наш код делает четыре прохода и строит три промежуточных списка, он всё равно может быть корректным — но будет просто тратить лишнее время и память.
Мы не будем измерять миллисекунды и строить графики профайлера (это отдельная история), но научимся читать пайплайн «по стоимости», как смету ремонта: «тут лишний слой штукатурки».
2. Стоимость пайплайна: проходы, коллекции и объекты
Когда вы видите цепочку вроде filter().map().filter().map(), у неё есть цена. И цена обычно состоит из трёх частей: сколько раз мы пробежали по исходным данным, сколько промежуточных коллекций создали между шагами, и сколько временных объектов наделали по дороге (например, новых строк, Pair, Triple и т.д.). Эта модель полезна даже без точных цифр: вы начинаете замечать «лишнее движение» в коде.
Давайте зафиксируем это в небольшой таблице — без фанатизма, чисто чтобы ориентироваться:
| Что происходит | Как проявляется в коде | Чем может аукнуться |
|---|---|---|
| Лишние проходы по данным | несколько шагов, каждый «снова по всем» | лишнее время CPU |
| Промежуточные коллекции | цепочка на List часто материализует результат каждого шага | лишняя память + сборка мусора |
| Временные объекты | часто создаём новые элементы (например, map { ... }) | нагрузка на GC, особенно при больших объёмах |
Важно: иногда читаемость важнее, и вы сознательно оставляете «два понятных шага» вместо одного умного. Но это должно быть осознанным выбором, а не случайностью.
Как мысленно «оценивать» пайплайн по исполнению
Полезно держать в голове простую картинку: представим пайплайн как конвейер.
flowchart TD
A[List: исходные данные] --> B[filter]
B --> C[map]
C --> D[filter]
D --> E[toList / fold / count]
Когда это List, многие шаги (условно B, C, D) часто создают промежуточные списки: результат filter — список, результат map — список, и так далее.
Когда это Sequence, шаги B, C, D обычно не материализуются, пока не случится терминальная операция E. И тогда элементы идут «по одному» через весь конвейер. Эта идея про «промежуточные vs терминальные» и «ленивость» — ключ к пониманию, почему Sequence может уменьшить количество промежуточных коллекций.
3. Лишние проходы и промежуточные коллекции
Лишние проходы: где они появляются «по привычке»
Очень типичный паттерн у новичков (и, честно, у взрослых разработчиков, когда они устали) выглядит так: «сначала отфильтрую», а потом «отдельно посчитаю». Это нормально… пока данных немного. Но полезно понимать, что это два прохода: один для filter, другой для fold/sum.
Допустим, у нас есть расходы в виде Triple(id, category, amount). Пока без ООП — мы в той части курса, где делаем проект «на функциях и коллекциях».
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "taxi", 250),
Triple(3, "food", 80),
)
val foodOnly = expenses.filter { it.second == "food" }
val totalFood = foodOnly.fold(0) { acc, e -> acc + e.third }
println(totalFood) // 200
}
Код читается идеально: сначала оставили только еду, потом сложили суммы. Но модель исполнения такая: мы прошли по expenses, построили новый список foodOnly, потом прошли по foodOnly ещё раз и посчитали сумму.
Сам fold — это стандартный способ «накопить итог», и он последовательно комбинирует аккумулятор с элементами.
Можно ли сделать один проход? Да. Всегда ли нужно? Нет. Но уметь — полезно.
Промежуточные коллекции: что происходит в цепочке на List
Когда вы вызываете операции вроде filter и map на обычной коллекции (List), вы обычно получаете новый список как результат каждого шага. То есть цепочка:
val result = xs.filter { ... }.map { ... }.filter { ... }
в концептуальной модели делает примерно так:
- построила список после первого filter
- построила список после map
- построила список после второго filter
И только потом вы увидели итог.
Это не «плохо» и не «ошибка Kotlin». Наоборот: такой контракт делает код предсказуемым, потому что каждый шаг даёт вам готовую коллекцию. Но если шагов много, а данных много, вы начинаете платить за каждый промежуточный список.
4. Sequence: когда помогает и когда мешает
Где Sequence реально помогает
Sequence полезен не тем, что он «магически быстрее всегда», а тем, что он меняет модель выполнения: операции становятся ленивыми, и элементы могут проходить через цепочку «потоком», без промежуточных списков — до тех пор, пока вы не попросите результат терминальной операцией.
Особенно сильный эффект появляется, когда у нас есть «раннее завершение»: например, take(n) или поиск первого подходящего элемента. Смысл такой: если нам нужно только 5 элементов, не надо обрабатывать 100_000.
Чтобы увидеть идею, сделаем пример не про расходы, а про числа (так проще воспринимать глазами). Сам приём тот же.
fun main() {
val xs = (1..1_000).toList()
val firstFive = xs.asSequence()
.map { it * it }
.filter { it % 10 == 0 }
.take(5)
.toList()
println(firstFive) // [100, 400, 900, 1600, 2500]
}
Здесь важно, что take(5) — операция «взять первые N». В ленивой модели это часто означает, что мы не будем обрабатывать хвост, который всё равно не попадёт в результат.
Если бы мы делали то же самое на List без Sequence, то мы, скорее всего, сначала построили бы список квадратов для всех 1000 чисел, потом отфильтровали, потом взяли 5. На 1000 элементах разницы почти нет, а на 10_000_000 — может быть уже очень заметно.
Где Sequence может мешать
Sequence — инструмент, а не религия. Иногда он усложняет жизнь, потому что ленивость добавляет «временной фактор»: не всегда очевидно, когда именно выполнится код внутри map/filter.
А ещё есть нюанс, который часто удивляет новичков: Sequence можно случайно «пройти» несколько раз, и тогда работа будет сделана несколько раз.
Посмотрим на пример. Мы сделаем цепочку и дважды запросим результат разными терминальными операциями.
fun main() {
val xs = listOf(1, 2, 3, 4)
val seq = xs.asSequence()
.map { x ->
println("map $x")
x * 10
}
println(seq.count()) // map 1..4 напечатается, count = 4
println(seq.toList()) // map 1..4 напечатается ЕЩЁ РАЗ
}
Вывод будет примерно такой (упрощённо):
map 1
map 2
map 3
map 4
4
map 1
map 2
map 3
map 4
[10, 20, 30, 40]
И это логично: Sequence — это не «готовый результат», а «описание того, как получить элементы». Поэтому если вы два раза попросили результат, то элементы два раза и посчитались.
На обычной List вы бы один раз построили новый список, а дальше он бы переиспользовался. Так что иногда Sequence не ускоряет, а наоборот добавляет работу, если вы постоянно «дёргаете результат» несколько раз.
Практическое правило звучит так: если вы сделали Sequence и хотите использовать итог больше одного раза, часто имеет смысл материализовать его в List один раз через toList() и дальше работать уже с ним.
Побочные эффекты как усилитель проблем
Есть ещё одна причина, почему Sequence иногда «мешает»: побочные эффекты (например, println) внутри map/filter становятся привязаны к моменту исполнения, а он откладывается до терминальной операции. Вы уже видели эту идею в прошлой лекции как «почему ничего не произошло».
Сегодня нам важно другое: побочные эффекты делают пайплайн сложнее для понимания и иногда ведут к повторной работе (как в примере выше), а повторная работа = повторные побочные эффекты. Если вы внутри map отправляете лог, то лог улетит столько раз, сколько вы запросили терминальный результат. Иногда это нормально. Иногда — «почему у меня 2000 одинаковых строк в логах».
В учебных примерах мы иногда используем println внутри цепочек специально, как подсветку. Но в реальном коде лучше держать преобразования «чистыми», а эффекты — отдельно. Это особенно важно, когда вы переходите к ленивым вычислениям.
5. Практика: отчёт по расходам без лишней работы
Сейчас давайте аккуратно привяжем всё к нашему практическому консольному проекту (условно назовём его BudgetCli). Напомню формат данных: список Triple(id, category, amount). Наша цель — сделать отчёт «топ‑N категорий по сумме».
Читабельный вариант отчёта
Сначала напишем максимально понятный вариант: собрать суммы по категориям, превратить это в список, отсортировать, взять take(n).
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "taxi", 250),
Triple(3, "food", 80),
Triple(4, "books", 300),
)
val totals = expenses.fold(mutableMapOf<String, Int>()) { acc, e ->
acc[e.second] = (acc[e.second] ?: 0) + e.third
acc
}
val top2 = totals.entries
.sortedByDescending { it.value }
.take(2)
println(top2) // [books=300, food=200]
}
Здесь fold делает понятную вещь: накапливает структуру (MutableMap) по одному элементу. Важно, что аккумулятором в fold может быть не только число — это нормальная идея агрегаций.
Но дальше есть момент: sortedByDescending возвращает новый список (то есть материализует результат сортировки), и только потом take(2) берёт первые два. Для маленького количества категорий это не страшно. Но если категорий очень много, сортировка всего списка ради top‑2 — дороговато.
Мы пока не будем изучать «продвинутые структуры» и алгоритмы выбора top‑N без полной сортировки (это другой пласт тем). Но мы можем хотя бы понимать: сортировка всего — это работа.
Где Sequence в проекте помогает, а где нет
В этом месте многие автоматически говорят: «О, давайте asSequence()». И вот здесь начинается взрослая инженерия: надо спросить себя — а что именно станет лучше?
Если у нас:
- мало данных (десятки/сотни),
- цепочка короткая (2–3 шага),
- результат нужен целиком,
то Sequence часто не даёт выигрыша, а иногда делает код чуть менее очевидным.
Но если у нас:
- цепочка длинная,
- есть потенциальное раннее завершение,
- много промежуточных шагов (особенно map/filter/map/filter/...),
то Sequence может уменьшить количество промежуточных коллекций.
Например, если мы хотим взять только первые несколько «подозрительных» операций (скажем, расходы больше 500), то Sequence + take(n) — хороший кандидат.
fun main() {
val expenses = listOf(
Triple(1, "food", 120),
Triple(2, "taxi", 250),
Triple(3, "books", 900),
Triple(4, "taxi", 700),
Triple(5, "food", 50),
)
val bigOnes = expenses.asSequence()
.filter { it.third >= 500 }
.take(2)
.toList()
println(bigOnes) // [(3, books, 900), (4, taxi, 700)]
}
Здесь take(2) очень важен: он даёт шанс не обрабатывать хвост списка, если первые два подходящих элемента нашлись рано. Сама идея take — «ограничить количество элементов», и она хорошо ложится на ленивую модель.
«Склейка шагов» вместо лишних коллекций
Иногда лучший способ ускорить пайплайн — не ленивость, а уменьшение шагов. Например, вместо:
filter → map → filterNotNull
можно использовать одну операцию mapNotNull. Вы это уже проходили в лекциях про очистку данных и смену типов.
В нашем проекте это полезно при разборе входных строк. Допустим, у нас есть сырые команды, и мы хотим извлечь только суммы (как Int), пропуская мусор.
fun main() {
val rawAmounts = listOf("120", " ", "oops", "250")
val amounts = rawAmounts.mapNotNull { it.trim().toIntOrNull() }
println(amounts) // [120, 250]
}
Тут мы сделали один проход и сразу получили «чистый» List<Int>. Если бы мы сделали map { ... } с возвратом Int?, а потом filterNotNull, было бы два шага. Опять же: иногда два шага читаемее, и это нормально. Но теперь вы хотя бы понимаете цену.
6. Практичное правило выбора: List или Sequence
Важно не превратить курс в культ Sequence, поэтому зафиксируем здравую интуицию. Если у вас цепочка короткая и понятная, а объём данных небольшой, то обычные операции на List — отличный выбор: читаемо, предсказуемо, легко дебажить. Kotlin-стандартная библиотека вообще рассчитана на то, что вы активно используете map/filter/reduce/fold и не боитесь этого.
Если цепочка длинная, данных много, и особенно если есть take(n) или поиск «первого подходящего», то Sequence становится полезным кандидатом, потому что может убрать промежуточные коллекции и остановиться раньше.
А вот если вы планируете несколько раз использовать результат, то Sequence иногда вреден: вы можете случайно посчитать одно и то же несколько раз. Тогда лучше материализовать в List один раз (через toList()) и дальше работать уже с готовыми данными.
7. Типичные ошибки при попытках «оптимизировать пайплайн»
Ошибка №1: оптимизация без понимания, что именно оптимизируем.
Очень легко заменить list.filter { ... }.map { ... } на list.asSequence().filter { ... }.map { ... }.toList() и гордо считать, что стало быстрее. Но если данных мало и цепочка короткая, вы могли ничего не выиграть, а код стал чуть менее прозрачным. Лучше сначала научиться видеть лишние шаги и промежуточные списки, а потом выбирать инструмент.
Ошибка №2: повторное использование Sequence как будто это готовый результат.
Sequence — это «рецепт», а не «готовое блюдо». Если вы сделали терминальную операцию дважды (например, count(), потом toList()), то рецепт приготовится дважды. Это особенно болезненно, если внутри пайплайна есть тяжёлые вычисления или побочные эффекты (println, логирование).
Ошибка №3: побочные эффекты внутри map/filter и удивление от «странного времени исполнения».
Когда код ленивый, побочные эффекты происходят не там, где вы их написали глазами, а там, где случилась терминальная операция. В результате вы можете получить «поздние» логи, дубли логов или ощущение, что программа «ничего не делает». Для учебных целей println внутри цепочки полезен, но в рабочем стиле лучше держать преобразования максимально чистыми.
Ошибка №4: слепая сортировка «всего» ради top‑N.
sortedByDescending(...).take(n) — удобный и читаемый паттерн, вы его уже использовали. Но важно понимать: сортируется весь список целиком, а потом вы берёте первые элементы. Иногда это нормально, иногда это самое тяжёлое место в отчёте. Сегодня мы не изучаем альтернативные алгоритмы выбора top‑N без полной сортировки, но сам факт «тут сортировка всего» вы должны научиться замечать.
Ошибка №5: попытка решить проблему промежуточных коллекций там, где проблема — лишние шаги.
Иногда вы делаете filter и потом ещё раз filter по почти тому же условию, или сначала map, а потом map снова, хотя можно было сделать один шаг. В таких местах Sequence может уменьшить количество промежуточных списков, но не уменьшит количество логических шагов. Часто более сильное улучшение — просто упростить пайплайн, например заменить map { ... }.filterNotNull() на mapNotNull { ... }.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ