JavaRush /Курси /Kotlin SELF /Замикання та побічні ефекти

Замикання та побічні ефекти

Kotlin SELF
Рівень 21 , Лекція 4
Відкрита

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.

1
Опитування
Лямбди та callable references, рівень 21, лекція 4
Недоступний
Лямбди та callable references
Лямбди та callable references
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ