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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ