1. Вступ
Коли ви вперше пишете локальну функцію, мозок радіє: «Клас! Можу сховати допоміжну логіку поруч із місцем використання й не засмічувати файл». А потім ви помічаєте, що локальна функція спокійно читає змінні із зовнішньої функції. І не лише читає — інколи ще й змінює їх.
Саме це і називається замиканням: локальна функція «замикає» на собі зовнішні змінні, доступні їй в області видимості.
Якщо сказати трохи формальніше (але без академічної строгості), замикання — це ситуація, коли функція використовує змінні, оголошені не всередині неї, а зовні, у зовнішньому блоці. У Kotlin це дозволено: замикання (closure) охоплює змінні із зовнішньої області видимості, і такі змінні можуть бути змінюваними.
Захоплення val: «контекст» без сюрпризів
Найдружніший вид замикання — коли локальна функція захоплює зовнішню змінну, оголошену як val. Чому це «дружньо»? Тому що val не можна призначити повторно, отже контекст стабільний. Функція не потрапить у ситуацію на кшталт: «сьогодні в мене префікс один, а завтра інший, бо хтось десь змінив значення».
Уявіть, що ми хочемо друкувати повідомлення з одним і тим самим префіксом. Можна щоразу передавати префікс параметром, але це швидко набридає. Локальна функція розвʼязує проблему: префікс оголошено один раз, а log(...) просто його використовує.
fun main() {
val prefix = "[INFO]"
fun log(message: String) {
println("$prefix $message")
}
log("Початок") // [INFO] Початок
log("Готово") // [INFO] Готово
}
Тут 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("Записів=$count, сума=$total $currency")
}
println("Введіть витрати або 'exit', щоб завершити.")
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("Записів=$count, сума=$total $currency")
}
while (true) {
val raw = readln().trim()
if (raw == "exit") break
val amount = raw.toIntOrNull()
if (amount == null) {
println("Це не число. Спробуйте ще раз.")
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("Сесію розпочато") // [EXPENSE] Сесію розпочато
}
Це читається так, ніби «логер має сталий контекст».
Тепер додамо лічильник помилок введення. Це вже захоплення var, і тут важливо не втратити контроль.
private fun runExpenseSession() {
var invalidInputs = 0
fun onInvalidInput(raw: String) {
invalidInputs += 1
println("Некоректно: '$raw' (помилок=$invalidInputs)")
}
val raw = readln().trim()
onInvalidInput(raw) // Некоректно: '...' (помилок=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: замикання захоплює надто багато зовнішніх змінних і перетворюється на магічну функцію.
Якщо локальна функція використовує пʼять зовнішніх змінних, то її поведінку стає важко пояснити в місці виклику. Вона починає виглядати як «щось, що саме розбереться». Зазвичай це знак, що локальну функцію варто спростити: або передати частину залежностей параметрами, або розділити її на дві маленькі функції. Кожна з них робитиме один крок і використовуватиме мінімум стану.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ