JavaRush /Курси /Kotlin SELF /forEach і побічні ефекти: коли це доречно

forEach і побічні ефекти: коли це доречно

Kotlin SELF
Рівень 22 , Лекція 0
Відкрита

1. Що таке forEach і чим він відрізняється від for

Коли ви бачите forEach, перша реакція зазвичай така: «Ага, це як цикл for, тільки модніший». І так, він справді обходить елементи. Але сенс у нього трохи інший: forEach — це виклик функції для кожного елемента, а не окрема конструкція мови. Через це змінюються нюанси: що можна або не можна робити всередині, як читається код і чому forEach найчастіше ставлять там, де потрібно виконати дію, а не «порахувати щось і повернути».

Якщо порівнювати дуже грубо, то for — це «я керую процесом обходу», а forEach — «колекція сама обходить себе, а я даю їй інструкцію, що робити з кожним елементом».

Корисно тримати в голові таку табличку (вона не про «правильно/неправильно», а про намір):

Інструмент Що це в Kotlin Типова мета Повернене значення
for (...) { ... }
конструкція мови обхід + контроль (break/continue) немає (це потік керування)
forEach { ... }
функція вищого порядку «зроби дію для кожного елемента»
Unit

У настановах Kotlin щодо стилю коду (Kotlin Coding Conventions) окремо згадується: під час вибору між звичайним циклом і forEach часто варто віддати перевагу for. Винятки — випадки на кшталт nullable-ресивера або ситуації, коли forEach є частиною ланцюжка викликів.

2. Сигнатура forEach і роль Unit

Коли ви починаєте користуватися функціями колекцій, у голові часто спрацьовує хитра звичка: «Раз це функція, значить, вона щось повертає — і я можу це зберегти в змінну». У випадку forEach — майже завжди ні. forEach призначений для виконання коду на кожному елементі, а «результат» у нього — побічний ефект (наприклад, друк у консоль). Тому forEach повертає Unit, тобто «нічого корисного як значення».

Подивімося на мініприклад:

fun main() {
    val names = listOf("Ann", "Bob", "Chris")

    names.forEach { name ->
        println("Привіт, $name") // Привіт, Ann ... і так далі
    }
}

Тут усе логічно: ми хочемо надрукувати привітання для кожного імені. Це і є мета forEach.

А ось що часто намагаються зробити новачки:

fun main() {
    val xs = listOf(1, 2, 3)

    val result = xs.forEach { x ->
        println(x)
    }

    println(result) // тут буде kotlin.Unit
}

result тут — це Unit. Тобто «ми зберегли в змінну сам факт того, що нічого не повернули». Якщо ви колись колекціонували фантики — вітаю, ви вже готові до цього стилю.

3. forEach заради дії: друк, лог, виклик функції

forEach особливо добрий там, де у вас є готові дані і ви хочете «застосувати» їх до світу: вивести на екран, записати в лог, кудись надіслати, викликати функцію, яка виконує зрозумілу дію. Найчесніша ознака того, що forEach доречний: усередині лямбди ви робите щось «назовні», а не будуєте нову структуру даних.

Друк — найчесніший побічний ефект

fun main() {
    val xs = listOf(10, 20, 30)
    xs.forEach { x ->
        println("x = $x") // x = 10, потім 20, потім 30
    }
}

Виклик функції «з ефектом»

Припустімо, у нас є функція, яка друкує попередження (це дія, а не обчислення):

fun warn(message: String) {
    println("ПОПЕРЕДЖЕННЯ: $message")
}

fun main() {
    val problems = listOf("Порожнє введення", "Хибний формат", "Поза діапазоном")
    problems.forEach { p ->
        warn(p) // ПОПЕРЕДЖЕННЯ: Порожнє введення ...
    }
}

Невелика схема: де живе сенс forEach

Іноді корисно уявити це так:

flowchart LR
    A[Колекція даних] --> B["forEach { ... }"]
    B --> C[ДІЯ: println / лог / виклик функції]

Це не «математика», це «виконавець»: дані → обробили → виконали дію.

4. Корисні прийоми з forEach

Callable reference ::println: коли «коротше» справді краще

Після лямбд ви дізналися про callable reference: :: дозволяє передати готову функцію туди, де очікується лямбда. Це особливо добре працює тоді, коли лямбда була б просто «виклич одну функцію з одним аргументом».

Наприклад, замість:

fun main() {
    val xs = listOf(1, 2, 3)
    xs.forEach { x -> println(x) } // 1 2 3
}

можна:

fun main() {
    val xs = listOf(1, 2, 3)
    xs.forEach(::println) // 1 2 3
}

Це виглядає так, ніби «прочитай по-людському»: «для кожного елемента — println».

Тут є важливий момент стилю: якщо лямбда стає складнішою, параметр краще назвати явно, а не використовувати it. У Kotlin Coding Conventions це теж окремо підкреслюється: короткі прості лямбди — it, вкладені або складні — краще з явними параметрами.

?.forEach: зручний патерн для nullable-колекцій

Є місце, де forEach справді виграє у звичайного for. Це випадок, коли колекція може бути null. Із for вам довелося б писати перевірку:

fun main() {
    val maybeItems: List<String>? = listOf("a", "b")

    if (maybeItems != null) {
        for (x in maybeItems) {
            println(x)
        }
    }
}

А з forEach виходить простий і гарний патерн:

fun main() {
    val maybeItems: List<String>? = listOf("a", "b")
    maybeItems?.forEach { println(it) } // a, потім b
}

Тут читається буквально як українське речення: «якщо список існує — зроби дію для кожного елемента».

І це якраз той випадок, який часто вважають виправданим для forEach: коли ?.forEach прибирає зайвий if і робить код коротшим, не перетворюючи його на загадку.

5. Побічні ефекти: що це і чому з ними потрібно дружити обережно

Словосполучення «побічний ефект» звучить так, ніби зараз розповідатимуть про ліки. Але в програмуванні все простіше: побічний ефект — це зміна чогось поза поверненим значенням. Друк у консоль — побічний ефект. Запис у файл — побічний ефект. Зміна зовнішньої змінної — теж побічний ефект.

forEach найчастіше використовують саме там, де побічні ефекти доречні. Важливо лише розуміти: якщо зловживати побічними ефектами, код стає менш передбачуваним. Умовно кажучи, якщо функція повертає число, ви можете перевірити її результат. Якщо ж функція «десь щось змінила» — перевіряти складніше.

«Накопичуємо суму» — можна, але усвідомлено

Цей приклад із серії «так можна, але памʼятайте, що ви робите»:

fun main() {
    val xs = listOf(10, 20, 30)

    var sum = 0
    xs.forEach { x ->
        sum += x // змінюємо зовнішню змінну (побічний ефект)
    }

    println("sum = $sum") // sum = 60
}

Чому це вважається побічним ефектом? Тому що sum знаходиться ззовні forEach, а ми його змінюємо.

Зауважте: я не кажу «так не можна». Я кажу: «так можна, але ви повинні розуміти, що ви увійшли в режим змінюваного стану».

«Збираємо рядок» — теж побічний ефект, але часто практичний

Оскільки ви вже знаєте StringBuilder, то ось акуратний приклад: ми не друкуємо на кожному кроці, а будуємо текст, який виведемо один раз:

fun main() {
    val names = listOf("Ann", "Bob", "Chris")
    val sb = StringBuilder()

    names.forEach { name ->
        sb.appendLine("Привіт, $name")
    }

    print(sb.toString())
    // Привіт, Ann
    // Привіт, Bob
    // Привіт, Chris
}

Технічно це теж побічний ефект (ми змінюємо sb), але він «локальний» і зрозумілий: builder створено поруч, використовується тут само, не «витікає» назовні. Це й робить код читабельним.

6. forEach у CLI-проєкті: друк списку витрат

Відсьогодні ми починаємо писати більш «колекційний» Kotlin-код, а наш практичний CLI-проєкт (умовний трекер витрат) — чудове місце, щоб застосувати forEach. У проєкті в нас уже є колекція витрат, і перше практичне завдання — красиво їх виводити. Це дія: ми не хочемо «отримати новий список». Ми хочемо показати користувачеві поточні дані.

Поки ми не перейшли до класів, зберігатимемо витрату як Triple(category, amount, note). Це неідеально, але чесно відображає поточний етап курсу: ми працюємо з базовими структурами. Пізніше це стане окремим типом — і життя стане легшим.

Мінімальні дані проєкту

fun main() {
    val expenses = mutableListOf(
        Triple("food", 250, "coffee"),
        Triple("transport", 120, "bus"),
        Triple("food", 560, "groceries"),
    )

    println("Витрати:")
    expenses.forEach { expense ->
        println(expense)
    }
    // Витрати:
    // (food, 250, coffee)
    // (transport, 120, bus)
    // (food, 560, groceries)
}

Це вже працює, але вивід «сируватий»: Triple друкується як (a, b, c).

Трохи краще: форматуємо кожен рядок

Зробімо маленьку функцію форматування і застосуємо її через forEach:

fun formatExpense(e: Triple<String, Int, String>): String {
    val (category, amount, note) = e
    return "$category: $amount ₴ ($note)"
}

fun main() {
    val expenses = listOf(
        Triple("food", 250, "coffee"),
        Triple("transport", 120, "bus"),
    )

    expenses.forEach { e ->
        println(formatExpense(e))
    }
    // food: 250 ₴ (coffee)
    // transport: 120 ₴ (bus)
}

Зверніть увагу: forEach тут максимально у своїй стихії. Ми зробили «чисту» функцію formatExpense, а forEach використали для дії — друку результату.

Друк табличкою: просте вирівнювання

Оскільки ви знаєте padEnd() (і загалом базову роботу з рядками), можна трохи причесати вивід:

fun main() {
    val expenses = listOf(
        Triple("food", 250, "coffee"),
        Triple("transport", 120, "bus"),
    )

    expenses.forEach { (category, amount, note) ->
        val left = category.padEnd(10)
        println("$left | ${amount.toString().padStart(5)} | $note")
        // food       |   250 | coffee
        // transport  |   120 | bus
    }
}

Тут ми показали ще один приємний трюк: деструктуризація прямо в параметрах лямбди. Це робить код коротшим і читабельнішим, коли в елемента кілька компонентів.

«Не чіпай колекцію, якою йдеш»

Оскільки forEach — це обхід, у багатьох зʼявляється бажання зробити всередині щось на кшталт expenses.remove(...). З погляду здорового глузду це схоже на «пиляти гілку, на якій сидиш»: іноді ви отримаєте помилку, іноді — дивну поведінку.

У цьому курсі можна домовитися так: усередині forEach ми не змінюємо колекцію, якою йдемо. Для модифікацій будуть доречніші стратегії (і окремі обговорення), а сьогодні ми тримаємо forEach як інструмент «пройтися й виконати дію».

7. Типові помилки під час роботи з forEach

Помилка №1: очікувати, що forEach побудує новий список.
Дуже поширена пастка: «я зараз forEach-ем помножу числа на 10 і отримаю нові числа». Але forEach повертає Unit, тобто він не про результат, а про дію. Якщо ви все-таки вручну збираєте новий список через mutableListOf() і add(), це працює, але фактично ви робите перетворення через побічні ефекти. Це не катастрофа, але такий код швидко стає менш читабельним. Уже в наступній лекції зʼявляться інструменти, які прямо виражають намір «отримай новий список».

Помилка №2: ховати складну бізнес-логіку всередину forEach.
Коли всередині лямбди у вас 15 рядків, три if і спроба згадати, як ви сюди потрапили, — час виносити код в окрему функцію. forEach добрий, коли лямбда або коротка, або викликає зрозумілу функцію. Інакше читання перетворюється на квест: «знайди, де закінчується лямбда, і чому воно все ще всередині неї».

Помилка №3: накопичувати стан через зовнішні змінні й дивуватися багам.
var sum = 0 і sum += x усередині forEach — це законно. Але якщо таких зовнішніх змінних стає кілька, ви починаєте писати «мініпрограму з прихованим станом». Потім додається ранній return, виняток або ще один прохід — і результат стає важко пояснити. Побічні ефекти варто використовувати, як гострий ніж: по ділу й не розмахуючи.

Помилка №4: намагатися використовувати break/continue як у звичайному циклі.
forEach — не мовний цикл, тому break усередині нього не працює. У Kotlin є варіанти на кшталт labeled return (return@forEach), але це окрема тема, і новачкам вона зазвичай додає більше плутанини, ніж користі. Якщо вам потрібен справжній «контроль циклу» (зупинитися, пропустити ітерацію, вийти за умовою) — найчастіше простіше й чесніше взяти звичайний for.

Помилка №5: зловживати it і втрачати сенс параметра.
it добрий, коли лямбда коротка й очевидна. Але якщо всередині ви звертаєтеся до кількох полів, форматуєте рядок і додаєте умови, краще назвати параметр (expense, name, x). Так код починає читатися як текст, а не як ребус. Kotlin style guide прямо підтримує цей підхід: короткі лямбди — it, вкладені або складні — явні параметри.

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