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 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, вложенные/сложные — явные параметры.

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