JavaRush /Курсы /Kotlin SELF /Замыкания: захват внешних переменных

Замыкания: захват внешних переменных

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

1. Введение

Когда вы впервые пишете локальную функцию, мозг радуется: «Класс! Могу спрятать вспомогательную логику рядом с местом использования и не засорять файл». А потом вы замечаете, что локальная функция спокойно читает переменные из внешней функции. И не только читает — иногда ещё и меняет.

Вот этот момент и называется замыканием: локальная функция «замыкает» на себе внешние переменные, которые ей доступны по области видимости.

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

Захват val: «контекст» без сюрпризов

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

Представьте, что мы хотим печатать сообщения с единым префиксом. Можно передавать префикс параметром каждый раз, но это быстро надоедает. Локальная функция решает проблему: префикс объявлен один раз, а log(...) его использует.

fun main() {
    val prefix = "[INFO]"

    fun log(message: String) {
        println("$prefix $message")
    }

    log("Start")  // [INFO] Start
    log("Done")   // [INFO] Done
}

Здесь log — локальная функция, а prefix — внешняя переменная, доступная по области видимости. Это и есть замыкание, но безопасного типа: мы ничего не меняем, только читаем.

Захват var: удобно, но появляется скрытое состояние

Если локальная функция захватывает внешнюю переменную var, то эта переменная становится общим изменяемым состоянием между внешней функцией и внутренней. Это может быть удобно (например, вести счётчик), но поведение начинает зависеть от порядка вызовов и от того, кто и когда менял переменную.

Начнём с самого простого примера: сумма, которую увеличиваем маленькой локальной функцией.

fun main() {
    var total = 0

    fun add(x: Int) {
        total += x
    }

    add(10)
    add(5)
    println(total) // 15
}

В этом коде add меняет внешнюю переменную total. С точки зрения компилятора это нормально: замыкание может изменять захваченные переменные.

Но у такого решения есть цена: если вы видите вызов add(10), то по сигнатуре add(x: Int) не видно, что она меняет «внешнюю» сумму. А значит состояние становится скрытым: оно не передаётся через параметры и не возвращается как результат, оно просто «где-то там» меняется.

Это не всегда плохо. Но дальше мы будем учиться чувствовать момент, когда «удобно» начинает превращаться в «почему оно сломалось, я же ничего не трогал».

Важная тонкость: замыкание видит текущее значение var

Частое заблуждение новичка звучит так: «Локальная функция как будто запоминает значение переменной на момент объявления». В Kotlin это не так: локальная функция обращается к переменной, и при каждом вызове она видит её актуальное значение.

Проверим на коротком примере:

fun main() {
    var mode = "A"

    fun printMode() {
        println("mode=$mode")
    }

    printMode()   // mode=A
    mode = "B"
    printMode()   // mode=B
}

Это важно для понимания рисков: если mode меняется в разных местах, то и поведение printMode() может меняться. Иногда это как раз нужно (например, режим отладки), но иногда это превращается в загадку: «почему лог сейчас печатает иначе?».

2. Практический пример: мини‑трекер расходов

Теперь сделаем пример ближе к «настоящему приложению», но всё ещё простой: консольный мини‑трекер расходов. Мы пока не храним список расходов (коллекции будут позже), а ведём только агрегаты: сумму и количество записей. Это удобно для демонстрации замыканий: состояние сессии живёт в переменных var, а локальные функции аккуратно его используют.

Мы хотим такой сценарий: пользователь вводит числа (расходы), а мы печатаем текущую сумму. Для выхода он вводит exit.

fun main() {
    runExpenseSession()
}

private fun runExpenseSession() {
    val currency = "USD"
    var total = 0
    var count = 0

    fun printStatus() {
        println("Entries=$count, total=$total $currency")
    }

    println("Enter expenses, or 'exit' to finish.")
    printStatus()
}

Обратите внимание: printStatus() захватывает currency, total, count. currencyval, а total/countvar. Это уже замыкание. Пока оно используется мирно: только читает.

Теперь добавим ввод и парсинг.

private fun runExpenseSession() {
    val currency = "USD"
    var total = 0
    var count = 0

    fun printStatus() {
        println("Entries=$count, total=$total $currency")
    }

    while (true) {
        val raw = readln().trim()
        if (raw == "exit") break

        val amount = raw.toIntOrNull()
        if (amount == null) {
            println("Not a number. Try again.")
            continue
        }

        total += amount
        count += 1
        printStatus()
    }
}

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

Добавим логирование: замыкание на val и на var

Добавим простой логгер. Начнём с безопасного варианта: логгер захватывает только val prefix.

private fun runExpenseSession() {
    val prefix = "[EXPENSE]"
    fun log(text: String) {
        println("$prefix $text")
    }

    log("Session started") // [EXPENSE] Session started
}

Это читается как «у логгера есть константный контекст».

Теперь добавим счётчик ошибок ввода. Это уже захват var, и тут важно не потерять контроль.

private fun runExpenseSession() {
    var invalidInputs = 0

    fun onInvalidInput(raw: String) {
        invalidInputs += 1
        println("Invalid: '$raw' (errors=$invalidInputs)")
    }

    val raw = readln().trim()
    onInvalidInput(raw) // Invalid: '...' (errors=1)
}

Если в маленькой функции это нормально, то в большом коде такой счётчик легко становится «привидением»: он растёт где-то внутри, и не всегда ясно почему.

3. Риски и читаемость: как не превратить код в детектив

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

Давайте сравним два подхода на игрушечном примере.

Вариант A: изменение внешнего состояния (замыкание на var):

private fun demoA() {
    var sum = 0

    fun add(x: Int) {
        sum += x
    }

    add(2)
    add(3)
    println(sum) // 5
}

Вариант B: функция возвращает новый результат (без скрытого состояния):

private fun demoB() {
    fun add(sum: Int, x: Int): Int {
        return sum + x
    }

    var sum = 0
    sum = add(sum, 2)
    sum = add(sum, 3)
    println(sum) // 5
}

В варианте B больше «рутины» (передаём sum туда-сюда), но логика прозрачна: видно, что меняется и где.

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

Как пользоваться замыканиями аккуратно

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

Обычно помогают несколько приёмов.

Во-первых, локальные функции, которые меняют внешнее состояние, полезно называть так, чтобы это читалось как действие над состоянием: addToTotal, incrementInvalidCount, setModeDebug. Тогда читатель не ожидает «чистой функции», а сразу понимает: сейчас что-то поменяется.

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

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

4. Типичные ошибки при работе с замыканиями

Перед тем как закончить, полезно проговорить самые частые «грабли», на которые наступают почти все. Замыкания — штука коварная: в маленьком примере они выглядят мило и удобно, а потом вы добавляете ещё пару условий и внезапно перестаёте понимать, почему программа печатает «не то». Эти ошибки не про синтаксис, а про стиль и ясность.

Ошибка №1: локальная функция незаметно меняет внешнюю var, и это не видно по имени.
Когда у вас есть fun add(x: Int) и где-то отдельно var total, читатель легко воспримет add как «просто вычисление» (особенно если он привык к математическим функциям). А потом оказывается, что add — это действие, которое меняет состояние. Если уж функция меняет внешнюю переменную, лучше назвать её так, чтобы это читалось как побочный эффект: addToTotal, increaseSum, registerExpense.

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

Ошибка №3: ожидание, что замыкание запомнит старое значение переменной.
Иногда разработчик думает: «Я объявил функцию, когда mode был "A", значит она и будет работать как в режиме A». Но локальная функция видит актуальное значение переменной при каждом вызове. Поэтому после mode = "B" поведение поменяется, и это нормально. Если вам нужно именно «зафиксировать» значение, то в рамках наших текущих инструментов проще всего сохранить его в отдельный val и уже его использовать внутри.

Ошибка №4: затенение имён, из-за которого вы читаете не ту переменную.
Когда снаружи var total, а внутри вы делаете fun add(total: Int), мозг начинает путаться даже у опытных людей. В результате вы можете обновлять не то, что планировали, или просто неверно интерпретировать код при чтении. Простая дисциплина имён (не повторять их во вложенных областях) резко снижает риск.

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

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