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 підхід «є пара функцій» — нормальна практика.

Що хочемо зробити Якщо колекція лише для читання (List) Якщо колекція змінювана (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), а потім знову відсортували, бо «щось не так виглядає».

Проблема тут не лише в продуктивності. Біда ще й у тому, що зміст починає розпливатися: стає незрозуміло, яке правило зрештою головне.

Подивімося на типову ситуацію: у нас є підсумкові суми за категоріями, і ми хочемо отримати топ-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("перетворюємо $it") // виконається пізніше
            it * 10
        }

    println("Перед toList()") // Перед toList()
    val ys = seq.toList()
    println("Після toList()")  // Після 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 на список пар і сортуйте явно, а потім рендерте текст. Це робить результат детермінованим і «спокійним».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ