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. currency — val, а total/count — var. Это уже замыкание. Пока оно используется мирно: только читает.
Теперь добавим ввод и парсинг.
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: замыкание захватывает слишком много внешних переменных и превращается в волшебную функцию.
Если локальная функция использует пять внешних переменных, то её поведение становится трудно объяснить по месту вызова. Она начинает выглядеть как «что-то, что само разберётся». Обычно это знак, что локальную функцию стоит упростить: либо передать часть зависимостей параметрами, либо разделить её на две маленькие функции, каждая из которых делает один шаг и использует минимум состояния.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ