1. Что такое forEach и чем он отличается от for
Когда видишь forEach, первая реакция обычно такая: «Ага, это как цикл for, только моднее». И да, он действительно обходит элементы. Но смысл у него немного другой: forEach — это вызов функции для каждого элемента, а не отдельная конструкция языка. Из-за этого меняются нюансы: что можно/нельзя делать внутри, как читается код, и почему forEach чаще всего ставят там, где нужно совершить действие, а не «посчитать что-то и вернуть».
Если сравнить очень грубо, то for — это «я управляю процессом обхода», а forEach — «коллекция сама обходит себя, а я даю ей инструкцию, что делать с каждым элементом».
Полезно держать в голове такую табличку (она не про «правильно/неправильно», а про намерение):
| Инструмент | Что это в Kotlin | Типичная цель | Возвращаемое значение |
|---|---|---|---|
|
конструкция языка | обход + контроль (break/continue) | нет (это поток управления) |
|
функция высшего порядка | «сделай действие для каждого элемента» | |
В 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("Hello, $name") // Hello, 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("WARNING: $message")
}
fun main() {
val problems = listOf("Empty input", "Wrong format", "Out of range")
problems.forEach { p ->
warn(p) // WARNING: Empty input ...
}
}
Небольшая схема: где живёт смысл forEach
Иногда полезно представить это так:
flowchart LR
A[Коллекция данных] --> B["forEach { ... }"]
B --> C[ДЕЙСТВИЕ: println / лог / вызов функции]
Это не «математика», это «исполнитель»: данные → обработали → сделали действие.
4. Полезные приёмы с forEach
Callable reference ::println: когда «короче» действительно лучше
После лямбд вы узнали callable references: :: позволяет передать готовую функцию туда, где ожидается лямбда. Это особенно красиво, когда лямбда была бы «просто вызови одну функцию с одним аргументом».
Например, вместо:
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 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("Hello, $name")
}
print(sb.toString())
// Hello, Ann
// Hello, Bob
// Hello, 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:")
expenses.forEach { expense ->
println(expense)
}
// Expenses:
// (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, вложенные/сложные — явные параметры.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ