JavaRush /Курсы /Kotlin SELF /Типичные ошибки пайплайнов

Типичные ошибки пайплайнов

Kotlin SELF
26 уровень , 3 лекция
Открыта

1. Почему пайплайны ломаются тихо

Пайплайны на коллекциях выглядят красиво: цепочка mapfiltergroupsorttake читается почти как предложение на русском. Но именно поэтому ошибки в них коварные: программа может успешно компилироваться и даже что-то выводить — просто не то. Мы сегодня потренируемся распознавать такие «тихие» поломки, прежде чем они дорастут до стадии «почему отчёт показывает ноль, хотя я точно тратил деньги».

Давайте закрепим базовую картинку: пайплайн — это последовательность преобразований формы данных, и на каждом шаге важно помнить, что именно возвращается.

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(), а у Listsorted().

Ошибка №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 в список пар и сортируйте явно, а затем рендерьте текст. Это делает результат детерминированным и спокойным.

1
Задача
Kotlin SELF, 26 уровень, 3 лекция
Недоступна
Порядок очков
Порядок очков
1
Задача
Kotlin SELF, 26 уровень, 3 лекция
Недоступна
Сообщения чата
Сообщения чата
1
Задача
Kotlin SELF, 26 уровень, 3 лекция
Недоступна
Частоты меток
Частоты меток
1
Задача
Kotlin SELF, 26 уровень, 3 лекция
Недоступна
Стабильный отчёт
Стабильный отчёт
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ