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("перевіряю $x") // побічний ефект
x > 0
}
println(quiet(10)) // true
println(noisy(10)) // перевіряю 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("парне=$it") }
forEachIf(xs, isEven, printEven)
// парне=2
// парне=4
}
Зауважте, наскільки простіше читати: predicate відповідає за «які», action — за «що зробити».
Робіть час життя замикання коротким
Одна з головних практичних небезпек — не саме захоплення var, а те, що лямбда із захопленим станом починає жити «надто довго». Наприклад, ви поклали її в змінну рівня файла або передали кудись далеко.
Намагайтеся тримати такі лямбди ближче до місця використання. Якщо вам потрібна лямбда-лічильник лише на один звіт — створіть її всередині функції звіту й не повертайте назовні.
Це нагадує принцип «не тримати кран відкритим»: якщо вода потрібна лише для того, щоб налити склянку, не залишайте кран відкритим на ніч.
7. Типові помилки
Помилка №1: предикат із побічними ефектами маскується під «просту перевірку».
Часто новачки роблять усередині предиката println, збільшують зовнішній лічильник або змінюють зовнішній прапорець, а потім дивуються, чому функція вищого порядку стала «шумною» або чому результати залежать від кількості викликів. Якщо функція за змістом — «умова», намагайтеся тримати її чистою, а ефекти переносити в окремий action.
Помилка №2: захоплення var використовують як «швидкий спосіб передати стан», і це вилізає під час повторного використання.
Ззовні здається: «ну подумаєш, var i, зараз пронумерую рядки». Але щойно ви запускаєте той самий код вдруге, лічильник не скидається, бо живе в замиканні. Якщо стан має починатися заново під час кожного запуску — тримайте його всередині функції або циклу, який і є «запуском».
Помилка №3: лямбда-«комбайн»: і перевіряє, і друкує, і накопичує статистику.
Такий код складно тестувати й важко читати: сигнатура каже одне, а робить він купу справ. Краще розділяти ролі: предикат повертає Boolean, action виконує дії назовні, а статистику рахує окрема функція або окремий крок.
Помилка №4: під час читання коду не помічають захоплення зовнішніх змінних.
Дуже типова ситуація: людина читає { x -> x > limit }, але не бачить, що limit — це зовнішня змінна, яка може змінюватися десь іще. Корисна звичка: коли бачите лямбду, очима пробігайтеся по іменах усередині й запитуйте себе: «це параметр, локальна val чи захоплена змінна?». Такий самий механізм працює і для локальних функцій — як у прикладах, де внутрішня функція використовує зовнішнє visited.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ