1. Почему пайплайны ломаются тихо
Пайплайны на коллекциях выглядят красиво: цепочка map → filter → group → sort → take читается почти как предложение на русском. Но именно поэтому ошибки в них коварные: программа может успешно компилироваться и даже что-то выводить — просто не то. Мы сегодня потренируемся распознавать такие «тихие» поломки, прежде чем они дорастут до стадии «почему отчёт показывает ноль, хотя я точно тратил деньги».
Давайте закрепим базовую картинку: пайплайн — это последовательность преобразований формы данных, и на каждом шаге важно помнить, что именно возвращается.
flowchart TD
A["List⟨Pair⟨String, Int⟩⟩ (расходы)"] --> B["Нормализация (map)"]
B --> C["Фильтрация (filter)"]
C --> D["Агрегация (groupBy+fold / groupingBy)"]
D --> E["Сортировка (sortedByDescending)"]
E --> F["Отбор (take)"]
F --> G["Рендер текста (StringBuilder)"]
Если в этой цепочке вы «потеряли» результат шага, перепутали операцию «возвращает новый список» с операцией «меняет на месте» или спрятали побочный эффект в map, то пайплайн превращается в красивую, но очень уверенную ложь.
2. Потеряли результат sorted/filter/map
Эта ошибка выглядит так невинно, что её хочется оправдать: «ну я же вызвал сортировку — значит список отсортировался». Но многие операции коллекций в Kotlin не мутируют источник, а возвращают новый результат. Например, sorted() создаёт новый список, в отличие от sort() у MutableList, который сортирует на месте. Поэтому рядом часто существуют пары функций: “in-place” и “возвращает новую коллекцию”, например sort() vs sorted().
Пример: почему список не отсортировался?
fun main() {
val amounts = listOf(30, 10, 20)
amounts.sorted() // результат потеряли
println(amounts) // [30, 10, 20] (ничего не изменилось)
}
Исправление — сохранить результат:
fun main() {
val amounts = listOf(30, 10, 20)
val sorted = amounts.sorted()
println(sorted) // [10, 20, 30]
println(amounts) // [30, 10, 20] (исходник живёт своей жизнью)
}
Мини-таблица: мутирует или возвращает?
Интуицию удобно закрепить маленькой шпаргалкой (не зубрёжкой, а «чтобы глаз привык»). Для коллекций в Kotlin идея «есть пара функций» — нормальная практика.
| Что хотим сделать | Если коллекция read-only (List) | Если коллекция mutable (MutableList) |
|---|---|---|
| Отсортировать | sorted() возвращает новый список | sort() сортирует «на месте» |
| Отфильтровать | filter() возвращает новый список | обычно тоже filter() возвращает новый; «на месте» вы фильтруете иначе, но это отдельная история |
| Преобразовать элементы | map() возвращает новый список | map() тоже возвращает новый |
В нашем практическом приложении (учёт расходов) это проявляется очень часто: вы строите отчёт, а потом «слегка сортируете расходы перед выводом» — и внезапно ничего не меняется, потому что сортировка не сохранилась.
3. map для побочных эффектов и список Unit
Эта ошибка появляется, когда мозг думает: «мне нужно пройтись по списку и что-то сделать», и рука автоматически тянется к map. Но map по смыслу — это «преобразуй каждый элемент в новый элемент и собери новый список». Если внутри вы делаете println, то результатом map становится список из Unit. И Kotlin честно вам его отдаёт.
Пример: анти-паттерн
fun main() {
val categories = listOf("food", "taxi")
val result = categories.map { println(it) } // печать = побочный эффект
// food
// taxi
println(result) // [kotlin.Unit, kotlin.Unit]
}
Иногда студенты смотрят на [kotlin.Unit, kotlin.Unit] и думают, что это какая-то новая коллекция из загадочных юнитов, которые нужно победить магией. На самом деле всё проще: println(...) возвращает Unit, вот map и собрал вам «результаты» — то есть Unit.
Исправление: если вы делаете действие, а не строите новую коллекцию
Самый прямой путь — обычный for:
fun main() {
val categories = listOf("food", "taxi")
for (c in categories) {
println(c) // food, taxi
}
}
Можно и forEach, но по курсу у нас часто проще начинать с for, потому что он не прячет магию. Главное — мысль: map не «для пройтись», а «для преобразовать».
В отчётах это важно особенно: если вы «внутри пайплайна» печатаете что-то для отладки, то получаете побочные эффекты, которые потом трудно убрать, и ещё сложнее понять, где именно они возникают.
4. groupBy для статистики и когда лучше eachCount
Когда нужна статистика вроде «частоты» или «сколько раз встретилось значение», многие по привычке делают groupBy, потому что оно знакомое. Но groupBy возвращает Map<K, List<V>>: то есть для каждого ключа строит список элементов группы. Это нормально, если вы дальше реально хотите работать со списком группы.
Но если вам нужна только статистика (например, «сколько расходов в каждой категории» или «сколько раз встречается слово»), то групповые списки становятся лишним грузом.
Пример: частоты категорий через groupBy (можно, но тяжеловато)
fun main() {
val categories = listOf("food", "taxi", "food")
val grouped = categories.groupBy { it } // Map<String, List<String>>
val counts = grouped.mapValues { (_, group) -> group.size }
println(counts) // {food=2, taxi=1}
}
Пример: то же самое через groupingBy().eachCount()
groupingBy() даёт объект Grouping, а eachCount() считает элементы по группам без построения списков групп.
fun main() {
val categories = listOf("food", "taxi", "food")
val counts = categories.groupingBy { it }.eachCount()
println(counts) // {food=2, taxi=1}
}
Для нашего приложения расходов это означает: если вы делаете отчёт «сколько операций по категориям», то eachCount() будет не только короче, но и ближе к смыслу задачи.
5. Две сортировки подряд и «волшебные» цепочки
Есть особый класс «ошибок» — когда результат правильный, но код делает лишние действия и становится трудно читаемым. Сортировка — частый пример: вы отсортировали по сумме, потом ещё раз по названию, потом взяли take(5), потом опять отсортировали, потому что «как-то не так выглядит».
Проблема здесь не только в производительности, а в том, что смысл начинает расплываться: непонятно, какое правило в итоге главное.
Давайте посмотрим на типичную ситуацию: у нас есть итоговые суммы по категориям, и мы хотим получить top-N. Хорошая мысль: сортировка должна быть один раз, ближе к «представлению» (то есть к выводу), а не размазана по всей логике.
Пример: сомнительная цепочка
fun main() {
val totals = listOf("taxi" to 250, "food" to 200, "books" to 90)
val top = totals
.sortedBy { it.first } // сортируем по имени
.sortedByDescending { it.second } // а потом по сумме (перезатираем идею)
.take(2)
println(top) // [(taxi, 250), (food, 200)] (может “случайно” совпасть, но логика мутная)
}
Да, Kotlin-сортировки стабильные (внутренности мы здесь не разбираем), но даже если результат «норм», код плохо объясняет намерение.
Пример: яснее — одно правило сортировки
fun main() {
val totals = listOf("taxi" to 250, "food" to 200, "books" to 90)
val top = totals
.sortedByDescending { it.second } // главный критерий
.take(2)
println(top) // [(taxi, 250), (food, 200)]
}
Если вам нужно «по сумме убыв., а при равенстве — по имени», тогда лучше выразить это как один критерий (через sortedWith + compareBy). Но здесь важнее поймать запах проблемы: «цепочка выглядит как магия».
6. Лишние проходы по данным
Эта ошибка не всегда заметна на маленьких списках (а на маленьких списках почти всё работает быстро). Но в отчётах она проявляется неприятно: вы пишете «красиво», а потом отчёт на 100 тысяч операций начинает тормозить, и вы не понимаете почему.
Частая причина — несколько независимых проходов, которые можно было объединить или хотя бы сделать более осознанно. Например, вы сначала вычисляете filtered, потом отдельно count, потом отдельно sum, причём каждый раз фильтрация повторяется.
Пример: повторяем одну и ту же фильтрацию два раза
fun main() {
val amounts = listOf(10, 200, 30, 500)
val countBig = amounts.filter { it >= 100 }.count()
val sumBig = amounts.filter { it >= 100 }.fold(0) { acc, x -> acc + x }
println(countBig) // 2
println(sumBig) // 700
}
Работает? Да. Делает два прохода с одинаковым фильтром? Тоже да.
В реальной жизни вы не всегда обязаны «оптимизировать», но вы обязаны понимать, что происходит. Часто достаточно просто вынести результат фильтра в val, и ваш код станет и быстрее, и читаемее.
Исправление: одна фильтрация — два вычисления
fun main() {
val amounts = listOf(10, 200, 30, 500)
val big = amounts.filter { it >= 100 }
val countBig = big.count()
val sumBig = big.fold(0) { acc, x -> acc + x }
println(countBig) // 2
println(sumBig) // 700
}
В отчётных функциях это особенно уместно: промежуточные val дают «точки остановки», где можно глазами проверить, что у вас за данные после шага.
7. Sequence: ленивость и забытый терминальный шаг
С Sequence происходит классическая история: вы читаете, что это «быстрее», включаете asSequence() — и дальше начинается мистицизм. На самом деле никакой мистики: Sequence ленивый. Промежуточные операции (map, filter) только описывают шаги, а реально вычисления запускаются на терминальной операции — например, toList().
Пример: я думал, что уже посчитал
fun main() {
val xs = listOf(1, 2, 3)
val seq = xs.asSequence()
.map { it * 10 }
println(seq) // kotlin.sequences.TransformingSequence@... (не список!)
}
Это не баг. Это вы напечатали объект-описание пайплайна, а не результат.
Исправление: материализуем результат
fun main() {
val xs = listOf(1, 2, 3)
val ys = xs.asSequence()
.map { it * 10 }
.toList()
println(ys) // [10, 20, 30]
}
Важный нюанс: побочные эффекты внутри Sequence выполняются позже
Если внутри map у вас println, то вы не контролируете момент печати так очевидно, как с обычным списком. Печать произойдёт только когда случится терминальная операция.
fun main() {
val xs = listOf(1, 2, 3)
val seq = xs.asSequence()
.map {
println("mapping $it") // выполнится позже
it * 10
}
println("Before toList()") // Before toList()
val ys = seq.toList()
println("After toList()") // After toList()
println(ys) // [10, 20, 30]
}
Этот эффект полезен, если вы отлаживаете, но вреден, если вы случайно смешали вычисление и вывод.
8. Map в отчёте и плавающий порядок
Когда вы строите отчёт, очень хочется сделать println(totalsByCategory) и радоваться. Но в реальном отчёте важнее другое: вывод должен быть предсказуемым. Даже если вы не пишете тесты, человек (или вы через неделю) хочет видеть один и тот же порядок строк.
С точки зрения типов всё логично: Map — это не «список строк», у него своя природа. Когда вам нужен порядок, обычно вы превращаете entries в список и сортируете явно.
Пример: рендер отчёта по категориям с явной сортировкой
fun renderTotalsByCategory(totals: Map<String, Int>): String {
val rows = totals.entries
.map { it.key to it.value }
.sortedBy { (cat, _) -> cat } // сортируем по имени категории
val sb = StringBuilder()
for ((cat, sum) in rows) {
sb.append(cat).append(": ").append(sum).append('\n')
}
return sb.toString()
}
И маленькая проверка:
fun main() {
val totals = mapOf("taxi" to 250, "food" to 200, "books" to 90)
print(renderTotalsByCategory(totals))
// books: 90
// food: 200
// taxi: 250
}
В этом коде у нас разделены «данные» и «представление»: сначала мы делаем упорядоченные строки данных (список пар), а потом собираем текст. Это помогает не только с порядком, но и с читабельностью.
9. Мини-разбор: отчёт расходов и типовые сбои
Сейчас мы возьмём наш привычный формат данных дня: список расходов List<Pair<String, Int>>, где first — категория, second — сумма. Нам важно не «написать идеальный отчёт», а увидеть, как мелкие ошибки в пайплайне превращают результат в неправильный или странный. Это та практика, где мозг перестаёт верить словам и начинает верить типам и возвращаемым значениям.
Исходные данные
fun main() {
val expenses = listOf(
" food " to 120,
"FOOD" to 80,
"taxi" to 250,
"books" to 90
)
println(expenses)
// [( food , 120), (FOOD, 80), (taxi, 250), (books, 90)]
}
Ошибка №1: забыли нормализацию до группировки
fun main() {
val expenses = listOf(" food " to 120, "FOOD" to 80)
val totals = expenses
.groupBy { it.first } // " food " и "FOOD" станут разными ключами
.mapValues { (_, items) -> items.fold(0) { acc, x -> acc + x.second } }
println(totals) // { food =120, FOOD=80} (категория “раздвоена”)
}
Почему так? Потому что groupBy() строит ключи ровно по тому, что вы ему дали.
Исправление: нормализуем ключ до группировки
fun normCategory(s: String): String = s.trim().lowercase()
fun main() {
val expenses = listOf(" food " to 120, "FOOD" to 80)
val totals = expenses
.map { (cat, amount) -> normCategory(cat) to amount }
.groupBy { it.first }
.mapValues { (_, items) -> items.fold(0) { acc, x -> acc + x.second } }
println(totals) // {food=200}
}
Ошибка №2: отсортировали, но потеряли результат
fun main() {
val totals = listOf("taxi" to 250, "food" to 200, "books" to 90)
totals.sortedByDescending { it.second } // потеряли результат
println(totals) // [(taxi, 250), (food, 200), (books, 90)] (как было)
}
Вам кажется, что «и так уже отсортировано», пока данные случайно в таком порядке. Но это ловушка: sortedByDescending возвращает новый список, а исходный не меняет.
Исправление: сохраняем результат
fun main() {
val totals = listOf("taxi" to 250, "food" to 200, "books" to 90)
val sorted = totals.sortedByDescending { it.second }
println(sorted) // [(taxi, 250), (food, 200), (books, 90)]
}
10. Типичные ошибки пайплайнов
Ошибка №1: вызвали sorted()/filter()/map() и не сохранили результат.
Это происходит, потому что многие операции возвращают новую коллекцию, а не меняют старую. Лечится привычкой: если операция не “in-place”, значит почти всегда нужен val. Подсказка: у MutableList есть sort(), а у List — sorted().
Ошибка №2: map используется как «просто пройтись и сделать действие».
Если внутри map живёт println, запись в внешний var или что-то подобное, то вы смешали преобразование и побочный эффект. Результат либо теряется, либо превращается в список Unit. Обычно помогает заменить на for и, если нужно, отделить расчёт от вывода.
Ошибка №3: groupBy используется там, где нужна только статистика.
groupBy создаёт Map<K, List<V>>. Если вы дальше берёте только size или просто считаете частоты, вы построили лишние списки. Часто проще и честнее по смыслу использовать groupingBy().eachCount().
Ошибка №4: несколько сортировок подряд «для надёжности».
Код начинает выглядеть как «ритуал»: вроде работает, но непонятно почему. Обычно лучше сформулировать одно правило сортировки (или одно составное правило), применить его один раз и только после этого делать take(n).
Ошибка №5: Sequence подключили, а терминальный шаг забыли.
Промежуточные операции на Sequence не выполняются сами по себе. Пока не произошло toList() (или другой терминальный шаг), вы держите «план вычислений», а не результат. И если внутри есть побочные эффекты, они сработают позже — в момент материализации результата в коллекцию.
Ошибка №6: отчёт печатает Map без явной сортировки и выдаёт «плавающий» порядок строк.
Когда вы строите отчёт для человека, порядок вывода — часть контракта. Если нужен порядок, превращайте entries в список пар и сортируйте явно, а затем рендерьте текст. Это делает результат детерминированным и спокойным.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ