1. Що таке forEach і чим він відрізняється від for
Коли ви бачите forEach, перша реакція зазвичай така: «Ага, це як цикл for, тільки модніший». І так, він справді обходить елементи. Але сенс у нього трохи інший: forEach — це виклик функції для кожного елемента, а не окрема конструкція мови. Через це змінюються нюанси: що можна або не можна робити всередині, як читається код і чому forEach найчастіше ставлять там, де потрібно виконати дію, а не «порахувати щось і повернути».
Якщо порівнювати дуже грубо, то for — це «я керую процесом обходу», а forEach — «колекція сама обходить себе, а я даю їй інструкцію, що робити з кожним елементом».
Корисно тримати в голові таку табличку (вона не про «правильно/неправильно», а про намір):
| Інструмент | Що це в Kotlin | Типова мета | Повернене значення |
|---|---|---|---|
|
конструкція мови | обхід + контроль (break/continue) | немає (це потік керування) |
|
функція вищого порядку | «зроби дію для кожного елемента» | |
У настановах 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, вкладені або складні — явні параметри.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ