JavaRush /Курсы /Kotlin SELF /Замыкания и побочные эффекты

Замыкания и побочные эффекты

Kotlin SELF
21 уровень , 4 лекция
Открыта

1. Что такое замыкание: лямбда с внешним контекстом

Когда вы только познакомились с лямбдами, кажется, что это просто «короткая функция прямо в месте вызова». Но реальность чуть хитрее: лямбда может читать переменные, которые объявлены снаружи, в окружающей области видимости. И это очень похоже на то, как вы берёте рюкзак: куда бы вы ни пошли, рюкзак едет с вами (а иногда в нём лежит что-то тяжёлое и подозрительное).

Технически это и есть замыкание: лямбда «замыкается» на внешние переменные и может их использовать. Причём это работает не только для лямбд, но и для локальных функций (функций внутри функции). В официальной документации Kotlin есть классический пример: локальная функция dfs использует переменную visited, объявленную во внешней функции — это типичный захват контекста замыканием.

Давайте поймаем интуицию на маленьком примере.

fun main() {
    val prefix = "[LOG]" // внешняя переменная

    val log: (String) -> String = { msg ->
        "$prefix $msg"   // лямбда использует prefix
    }

    println(log("start")) // [LOG] start
}

Здесь prefix живёт снаружи, а log использует его внутри. Никакого «протягивания параметром» мы не делали, но доступ есть.

2. Захват val и var: где безопасно, а где начинается «состояние»

Самый «дружелюбный» вид замыкания — когда лямбда захватывает val, то есть неизменяемую переменную. В этом случае в голове легко удержать модель: «лямбда использует настройку». Почти как функция с дополнительным параметром, только параметр спрятан в окружении.

Это часто используют, чтобы создавать «настроенные» предикаты или преобразователи. Например, нам нужен предикат «строка длиннее N». N — это настройка, а сама проверка остаётся чистой.

fun longerThan(minLen: Int): (String) -> Boolean {
    return { s -> s.length > minLen } // minLen захвачен
}

fun main() {
    val isLong = longerThan(3)

    println(isLong("go"))      // false
    println(isLong("kotlin"))  // true
}

Обратите внимание на важный психологический момент: isLong выглядит как обычная функция (String) -> Boolean, и она действительно «просто проверяет». Никакой скрытой магии.

Мини-схема: «настройка» как внешний val

flowchart LR
    A["minLen=3 (val)"] --> B["лямбда (String)->Boolean"]
    B --> C["вызов: isLong('kotlin')"]
    C --> D[true]

В целом можно запомнить простое правило: захват val чаще всего безопасен, потому что значение не меняется, а значит поведение лямбды стабильно.

Теперь начинается тот самый момент, когда код превращается в детектив: «почему оно вчера работало, а сегодня — нет». Если лямбда захватывает var, то она получает доступ к изменяемому состоянию, а значит:

  • поведение лямбды может зависеть от прошлого,
  • один и тот же вызов лямбды может дать разные эффекты, если состояние поменялось,
  • самое неприятное — по типу (T) -> Boolean это вообще не видно.

Посмотрим пример «невинного» предиката, который внезапно начинает считать, сколько раз его вызывали.

fun main() {
    var checks = 0

    val isEvenWithCounter: (Int) -> Boolean = { x ->
        checks++              // побочный эффект
        x % 2 == 0
    }

    println(isEvenWithCounter(2)) // true
    println(isEvenWithCounter(3)) // false
    println("checks=$checks")     // checks=2
}

Формально предикат «проверяет чётность». Фактически он ещё и меняет внешний checks. Это и есть скрытое состояние: читатель кода видит «предикат», а получает «предикат + счётчик».

Иногда такое нужно (например, логирование или сбор статистики), но проблема в том, что это легко сделать случайно и потом долго удивляться последствиям.

3. Побочные эффекты и «чистота» функций высшего порядка

Когда мы говорим «побочный эффект», мы имеем в виду действие, которое выходит за границы «вернул значение и всё». Например, печать в консоль, изменение внешней переменной, запись в файл, добавление в список. Лямбда может быть чистой (только вычисляет и возвращает) или нечистой (делает что-то ещё).

Важно понимать: побочные эффекты не запрещены. Kotlin не приходит к вам ночью и не забирает клавиатуру за println. Но если побочный эффект спрятан там, где вы ожидаете «простую функцию», код становится менее предсказуемым.

Сравним два предиката. Оба по типу (Int) -> Boolean, но один «тихий», второй «разговорчивый».

fun main() {
    val quiet: (Int) -> Boolean = { it > 0 }

    val noisy: (Int) -> Boolean = { x ->
        println("checking $x") // побочный эффект
        x > 0
    }

    println(quiet(10)) // true
    println(noisy(10)) // checking 10
                      // true
}

Один просто возвращает true/false. Другой каждый раз печатает. Иногда это удобно для отладки, но если вы передадите такой предикат в функцию высшего порядка, «внезапные» печати могут засорить вывод и сделать поведение программы шумным.

Сейчас возьмём функцию высшего порядка, которая выглядит честно и просто: «посчитай, сколько элементов удовлетворяют предикату». Она, на первый взгляд, чистая: вход → вычисление → результат.

fun countMatching(items: List<Int>, predicate: (Int) -> Boolean): Int {
    var count = 0
    for (x in items) {
        if (predicate(x)) count++
    }
    return count
}

А теперь передадим предикат, который захватывает внешний var и меняет его.

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

    var hits = 0
    val p: (Int) -> Boolean = { x ->
        val ok = x > 0
        if (ok) hits++     // побочный эффект: меняем внешний var
        ok
    }

    val result = countMatching(xs, p)

    println("result=$result hits=$hits") // result=2 hits=2
}

Проблема не в том, что это «не работает». Работает. Проблема в том, что countMatching больше нельзя воспринимать как “просто считает”. Она «просто считает» только для чистых предикатов. А с нечистыми предикатами она становится частью более сложного поведения.

И это тот момент, когда важно научиться задавать себе вопрос при чтении кода: а что эта лямбда захватила снаружи?

4. Как быстро замечать замыкания при чтении кода

Когда в коде появляется лямбда, полезно не только понять «что она делает», но и буквально «что она трогает». Это похоже на проверку карманов перед стиркой: если забыть ключи, потом будет весело, но не вам.

Есть простой мысленный алгоритм.

Сначала вы смотрите на параметры лямбды: { x -> ... }. Затем на тело: какие имена используются. Если внутри встречаются переменные, которые не являются параметрами и не объявлены внутри лямбды (через val/var), значит они взяты из внешней области видимости — то есть лямбда является замыканием.

Локальные функции ведут себя аналогично: если внутренняя функция использует переменную из внешней, она тоже замыкается на неё. В примере из документации Kotlin локальная функция dfs(current: Person) использует visited, объявленный снаружи — это ровно тот же механизм захвата внешнего состояния.

5. Практика: отчёты по расходам и «скользкое» состояние

Мы уже несколько дней строим маленькое консольное приложение со списком сущностей и командами вроде add/list/remove (вы делали такие штуки на коллекциях и обходах). Сегодня не будем перепридумывать мир заново: добавим к нашему приложению простые отчёты, и на них как раз удобно увидеть, где замыкания помогают, а где мешают.

Пусть «расход» пока хранится без ООП (классы будут сильно позже), как Triple:

  • first — id
  • second — категория
  • third — сумма
fun main() {
    val expenses = mutableListOf<Triple<Int, String, Int>>()

    expenses.add(Triple(1, "food", 120))
    expenses.add(Triple(2, "transport", 60))
    expenses.add(Triple(3, "food", 300))

    println(expenses.size) // 3
}

Полезное замыкание: фильтр по минимальной сумме

Сделаем функцию, которая печатает расходы, подходящие под условие. Мы специально используем цикл for, чтобы держаться материала текущего дня и не уходить в «волшебные» операции стандартной библиотеки.

fun printExpensesIf(
    expenses: List<Triple<Int, String, Int>>,
    predicate: (Triple<Int, String, Int>) -> Boolean
) {
    for (e in expenses) {
        if (predicate(e)) {
            println(e)
        }
    }
}

Теперь создадим предикат через замыкание, где minAmount — это внешний val-параметр настройки.

fun minAmountPredicate(minAmount: Int): (Triple<Int, String, Int>) -> Boolean {
    return { e -> e.third >= minAmount }
}

fun main() {
    val expenses = listOf(
        Triple(1, "food", 120),
        Triple(2, "transport", 60),
        Triple(3, "food", 300),
    )

    val bigOnly = minAmountPredicate(100)
    printExpensesIf(expenses, bigOnly)
    // (1, food, 120)
    // (3, food, 300)
}

Это хороший пример «правильного» замыкания: minAmount захвачен, но не меняется, и предикат остаётся чистым.

Опасное замыкание: нумерация строк через захваченный var

Теперь представим, что вы хотите в отчёте красиво нумеровать строки. Возникает соблазн: «давайте заведём var i снаружи и будем увеличивать внутри лямбды». Это работает… но при повторном использовании лямбды вы можете получить неожиданную нумерацию.

fun main() {
    val expenses = listOf(
        Triple(1, "food", 120),
        Triple(2, "transport", 60),
    )

    var i = 1
    val printLine: (Triple<Int, String, Int>) -> Unit = { e ->
        println("${i}. id=${e.first} ${e.second} ${e.third}")
        i++
    }

    for (e in expenses) printLine(e)
    // 1. id=1 food 120
    // 2. id=2 transport 60

    for (e in expenses) printLine(e)
    // 3. id=1 food 120
    // 4. id=2 transport 60
}

Если вы ожидали, что второй проход снова начнётся с 1, то сюрприз: i уже изменён. То есть printLine теперь зависит от прошлого, хотя по типу это просто (Expense) -> Unit.

Иногда это допустимо (например, вы делаете ровно один проход и лямбда нигде больше не используется). Но если такой обработчик передаётся в разные места, хранится в переменной, вызывается из разных функций — вы получаете «состояние, которое живёт дольше, чем вы думаете».

6. Паттерны: как держать эффекты заметными и локальными

Когда начинаются проблемы с замыканиями, обычно хочется «запретить себе всё и жить в монастыре чистых функций». На практике лучше освоить несколько спокойных привычек, которые делают код предсказуемым, даже если побочные эффекты нужны.

Если есть счётчик — пусть он живёт там, где считают

Если ваша цель — посчитать что-то, лучше возвращать это значением, а не менять внешний var. Это особенно актуально для предикатов: предикат должен отвечать на вопрос true/false, а не «и ещё немножко вести бухгалтерию сбоку».

Сравним два подхода. Сначала «скрытый счётчик»:

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

    var evens = 0
    val p: (Int) -> Boolean = { x ->
        val ok = x % 2 == 0
        if (ok) evens++
        ok
    }

    val count = countMatching(xs, p)
    println("count=$count evens=$evens") // count=2 evens=2
}

А теперь более честный вариант: считаем внутри и возвращаем.

fun countEvens(xs: List<Int>): Int {
    var evens = 0
    for (x in xs) if (x % 2 == 0) evens++
    return evens
}

fun main() {
    println(countEvens(listOf(1, 2, 3, 4))) // 2
}

Второй вариант банальнее, зато по нему невозможно «не догадаться», что происходит.

Если эффект нужен — отделяйте action от predicate

Если вам нужно и отбирать элементы, и печатать — не запихивайте печать в предикат. В прошлой лекции мы делали функцию с predicate и action. Это отличная «архитектурная микропривычка»: эффект живёт там, где ему место.

fun forEachIf(
    items: List<Int>,
    predicate: (Int) -> Boolean,
    action: (Int) -> Unit
) {
    for (x in items) {
        if (predicate(x)) action(x)
    }
}

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

    val isEven: (Int) -> Boolean = { it % 2 == 0 }
    val printEven: (Int) -> Unit = { println("even=$it") }

    forEachIf(xs, isEven, printEven)
    // even=2
    // even=4
}

Заметьте, насколько проще читать: предикат отвечает за «какие», action — за «что сделать».

Делайте время жизни замыкания коротким

Одна из главных практических опасностей — не сам захват var, а то, что лямбда с захваченным состоянием начинает жить «слишком долго». Например, вы положили её в переменную уровня файла, или передали куда-то далеко.

Старайтесь держать такие лямбды ближе к месту использования. Если вам нужна лямбда-счётчик только на один отчёт — создайте её внутри функции отчёта и не возвращайте наружу.

Это напоминает принцип «не хранить открытым кран»: если вода нужна только чтобы налить стакан, не оставляйте кран открытым на ночь.

7. Типичные ошибки

Ошибка №1: предикат с побочными эффектами маскируется под “простую проверку”.
Часто новички делают внутри предиката println, увеличивают внешний счётчик или меняют внешний флаг, а потом удивляются, почему функция высшего порядка стала «шумной» или результаты зависят от количества вызовов. Если функция по смыслу “условие”, старайтесь держать её чистой, а эффекты переносить в отдельный action.

Ошибка №2: захват var используется как “быстрый способ передать состояние”, и это вылезает при повторном использовании.
Снаружи кажется: «ну подумаешь, var i, сейчас пронумерую строки». Но как только вы запускаете тот же код второй раз, счётчик не сбрасывается, потому что живёт в замыкании. Если состояние должно начинаться заново при каждом запуске — держите его внутри функции/цикла, который и является “запуском”.

Ошибка №3: лямбда “комбайн”: и проверяет, и печатает, и копит статистику.
Такой код сложно тестировать и трудно читать: сигнатура говорит одно, а делает он полк дел. Лучше разделять роли: предикат возвращает Boolean, action делает действия наружу, а статистика считается отдельной функцией или отдельным шагом.

Ошибка №4: при чтении кода не замечают захват внешних переменных.
Очень типичная ситуация: человек читает { x -> x > limit }, но не видит, что limit — это внешняя переменная, которая может меняться где-то ещё. Полезная привычка: когда видите лямбду, глазами пробегайте по именам внутри и спрашивайте себя: “это параметр, локальная val или захваченная переменная?”. Такой же механизм работает и для локальных функций, как в примерах, где внутренняя функция использует внешнее visited.

1
Задача
Kotlin SELF, 21 уровень, 4 лекция
Недоступна
Форматтер логов
Форматтер логов
1
Задача
Kotlin SELF, 21 уровень, 4 лекция
Недоступна
Фильтр длины
Фильтр длины
1
Задача
Kotlin SELF, 21 уровень, 4 лекция
Недоступна
Счётчик подходящих
Счётчик подходящих
1
Задача
Kotlin SELF, 21 уровень, 4 лекция
Недоступна
Условие и действие
Условие и действие
1
Опрос
Лямбды и callable references, 21 уровень, 4 лекция
Недоступен
Лямбды и callable references
Лямбды и callable references
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ