JavaRush /Курси /Kotlin SELF /Відписка без болю — токен відписки та «посилкова» природа...

Відписка без болю — токен відписки та «посилкова» природа лямбд

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

1. Навіщо потрібна відписка

Коли ви вперше пишете події, часто з’являється відчуття: «підписався — і все, хай працює вічно». Воно тримається рівно до моменту, коли ви кілька разів «тимчасово ввімкнули логування», а потім здивувалися, що воно друкується тричі. Або коли «тимчасовий обробник» продовжує жити, хоча сценарій уже завершився. Відписка — це не примха архітектора. Це спосіб тримати поведінку програми передбачуваною й не накопичувати зайвих реакцій.

Уявімо наш навчальний консольний проєкт (нехай це буде трекер витрат). Сервіс публікує подію «витрату додано», а різні частини системи реагують: аудит пише в лог, UI друкує повідомлення, статистика оновлює лічильники.

data class Expense(val title: String, val amount: Int)

class ExpenseService {
    val expenseAdded = Event<Expense>()

    fun addExpense(title: String, amount: Int) {
        val e = Expense(title, amount)
        println("Додано: $e") // Додано: Expense(title=Coffee, amount=250)
        expenseAdded.emit(e)
    }
}

Поки все добре. Але далі починається «життя»: аудит ми хочемо вмикати й вимикати, повідомлення в консоль — теж потрібні не завжди, а тестовий обробник узагалі має бути тимчасовим. Отже, нам потрібна нормальна відписка.

2. Чому «просто видалити слухача» складніше, ніж здається

Коли в нас є список, здається логічним: «ну видалю зі списку — і все». Проблема в тому, що слухач — це функція, а в Kotlin функції можна зберігати як значення (лямбди, посилання на функції). Це окрема суперсила мови: функції можна класти в змінні, колекції, передавати як параметри й повертати з функцій.

Але як видалити функцію зі списку? Потрібно точно знати, яку саме функцію ви додавали. І тут на вас чекає класична пастка новачка: «я ж напишу таку саму лямбду ще раз».

Дивіться, як виглядає «наївна спроба»:

val event = Event<String>()

fun main() {
    event.subscribe { println("Отримано: $it") }

    // Ми намагаємося «відписатися» схожою лямбдою…
    // Але такої функції в нашому API навіть немає, і це не випадково.
}

Навіть якби ми зробили метод unsubscribe(listener), ви б дуже швидко дізналися, що «та сама лямбда за текстом» — це не «та сама лямбда за посиланням». У програмуванні це як намагатися повернути в магазин «точно таку саму» футболку, але не ту, яку ви купили: виглядає схоже, а чек не збігається.

3. Функції як значення і «посилковість» лямбд

Що саме ми кладемо в listeners

У Kotlin функції мають тип. Наприклад, обробник, який приймає Expense і нічого не повертає, має тип (Expense) -> Unit. Це називається типом функції. Kotlin прямо використовує такий запис, щоб описувати «функції як значення».

Давайте трохи «приземлимо» це на простий приклад, щоб мозок не намагався втекти у відпустку:

fun main() {
    val printer: (String) -> Unit = { text ->
        println("ДРУК: $text")
    }

    printer("Привіт") // ДРУК: Привіт
}

Тут printer — звичайна змінна. Просто всередині лежить «шматок коду». І коли ми робимо subscribe(listener), то фактично кладемо такі значення в список:

class Event<T> {
    private val listeners = mutableListOf<(T) -> Unit>()

    fun subscribe(listener: (T) -> Unit) {
        listeners += listener
    }

    fun emit(value: T) {
        for (l in listeners) l(value)
    }
}

Ключова деталь: у списку лежать конкретні об’єкти-функції. І щоб видалити елемент, потрібно мати посилання на той самий об’єкт.

Чому «така сама» лямбда — не та сама

Зараз буде важливий момент, який заощаджує багато нервів і кави (або створює привід для кави — залежить від того, як ви до цього ставитеся).

Дві лямбди, які виглядають однаково, зазвичай є двома різними об’єктами:

fun main() {
    val a: () -> Unit = { println("Привіт") }
    val b: () -> Unit = { println("Привіт") }

    println(a == b)   // false
    println(a === b)  // false
}

Чому так? Тому що в більшості випадків лямбда — це об’єкт, а новий літерал лямбди — це новий об’єкт. Якщо ви хочете видалити «той самий обробник», вам потрібне посилання на нього: та сама змінна, той самий об’єкт.

fun main() {
    val a: () -> Unit = { println("Привіт") }
    val c = a

    println(a === c) // true
}

Висновок простий і трохи сумний: щоб відписатися, потрібно або десь зберігати посилання на обробник, або зробити API, який сам дасть вам спосіб відписатися. І без вимоги «вгадай-но, який listener я додавав годину тому».

І от тут з’являється наш герой: токен відписки.

4. Токен відписки та контракт subscribe

Ідея токена

Ідея токена відписки звучить майже по-банківськи: «ось вам токен, зберігайте, не загубіть». Але по суті це дуже проста й зручна річ: subscribe(...) повертає об’єкт (або функцію), який дозволяє зняти підписку пізніше.

Ми зробимо токен максимально простим. Це буде функція () -> Unit, яку можна викликати, щоб відписатися.

Чому саме функція? Тому що Kotlin уміє зберігати й повертати функції як значення. І це природно лягає на нашу задачу.

Реалізація Event<T> з токеном

Ось оновлений Event<T>:

class Event<T> {
    private val listeners = mutableListOf<(T) -> Unit>()

    fun subscribe(listener: (T) -> Unit): () -> Unit {
        listeners += listener

        // Повертаємо токен відписки: викликав -> слухач видалився
        return { listeners.remove(listener) }
    }

    fun emit(value: T) {
        for (l in listeners) l(value)
    }
}

Що тут сталося «магічно», але насправді — цілком логічно:

Функція, яку ми повертаємо ({ listeners.remove(listener) }), захоплює (capture) дві речі із зовнішнього контексту: listeners і listener. Це і є замикання (closure): функція, що повертається, «пам’ятає», що саме потрібно видалити.

Використання токена

Тепер користувацький код стає дуже «людським»: підписалися — отримали токен — а потім викликали токен, коли треба.

fun main() {
    val event = Event<String>()

    val unsubscribe = event.subscribe { text ->
        println("Отримано: $text")
    }

    event.emit("A")     // Отримано: A
    unsubscribe()
    event.emit("B")     // (нічого не друкується)
}

Зверніть увагу: ми не шукали лямбду «за запахом», не порівнювали «однаковість тексту» й не лізли всередину Event за приватними полями (і слава Kotlin, що не дали). Ми просто зберегли токен.

Повторна відписка не має ламати програму

Дуже часте питання: «а якщо я випадково викличу токен двічі?». В ідеалі відповідь має бути проста: «нічого страшного».

Наш варіант listeners.remove(listener) уже достатньо безпечний. Якщо елемента немає, remove просто поверне false і не впаде. Це зручний «бонус» стандартної колекції.

Перевірмо поведінку на маленькому прикладі:

fun main() {
    val e = Event<Int>()

    val unsub = e.subscribe { println("x=$it") }

    unsub()
    unsub() // повторний виклик — ок, просто вже нічого видаляти
}

Це і є хороший контракт для токена: його можна викликати хоч двічі, і програма не перетвориться на гарбуз.

Невелика схема: що повертає subscribe

Іноді корисно побачити це як потік даних, а не як магію «воно само запам’ятало».

flowchart TD
    A["subscribe(listener)"] --> B["listeners += listener"]
    B --> C["return unsubscribeToken"]
    C --> D["unsubscribeToken()"]
    D --> E["listeners.remove(listener)"]

Сенс схеми простий: токен — це не «ID підписки з бази даних». Це просто функція, яка замикає (захоплює) потрібні змінні й уміє видалити слухача.

5. Приклад: «аудит увімкнути / аудит вимкнути»

Тепер давайте «приземлимо» цю механіку в наш трекер витрат. Уявімо, що аудит — це підписник, який друкує в консоль рядок «AUDIT: ...». Ми хочемо вмикати й вимикати його за командою (або налаштуванням), не додаючи обробник повторно щоразу.

Зробімо це максимально прямолінійно: зберігатимемо токен відписки в змінній.

class AuditFeature(private val service: ExpenseService) {
    private var unsubscribe: (() -> Unit)? = null

    fun enable() {
        if (unsubscribe != null) return

        unsubscribe = service.expenseAdded.subscribe { e ->
            println("AUDIT: додано ${e.title} на ${e.amount}") // AUDIT: додано Coffee на 250
        }
    }

    fun disable() {
        unsubscribe?.invoke()
        unsubscribe = null
    }
}

Тут є одразу два приємні ефекти. По-перше, enable() став ідемпотентним: якщо аудит уже ввімкнено, вдруге він не підписується. По-друге, disable() безпечний: якщо аудит уже вимкнено, unsubscribe буде null, і нічого поганого не станеться.

6. Порівняння підходів і дублікати підписок

Чому токен кращий за unsubscribe(listener)

Іноді хочеться зробити API «дорослішим» і додати метод unsubscribe(listener). Формально це можливо, але майже завжди незручно: вам доводиться десь зберігати listener, а новачки регулярно створюють «нову таку саму лямбду» й дивуються, чому відписка не працює.

Токен відписки прибирає цю проблему: слухача не треба шукати, токен уже «пам’ятає», що саме видалити.

Підхід Як виглядає Що незручно в житті
Відписка «за listener»
unsubscribe(listener)
Потрібно зберігати посилання на listener і передавати той самий об’єкт, інакше не видалиться
Відписка «за токеном»
val unsub = subscribe(...)
Потрібно зберігати токен, але він зазвичай простіший і зрозуміліший (одна змінна)

Якщо один і той самий listener підписали двічі

Зараз буде шматочок реальності. Ми домовилися (у межах мінімального контракту), що subscribe не робить дедуплікації. Отже, теоретично можна підписати один і той самий об’єкт-обробник двічі, і тоді він викличеться двічі.

fun main() {
    val e = Event<String>()

    val listener: (String) -> Unit = { println("Отримано: $it") }

    val u1 = e.subscribe(listener)
    val u2 = e.subscribe(listener)

    e.emit("X")
    // Отримано: X
    // Отримано: X

    u1()
    e.emit("Y")
    // Отримано: Y   (одна підписка ще лишилася)

    u2()
    e.emit("Z")
    // (тиша)
}

Така поведінка логічна, якщо думати «це список підписок», а не «множина унікальних обробників». Головне — вважати це частиною контракту й не дивуватися. Якщо колись вам знадобиться інша політика (наприклад, заборона дублікатів), це буде окреме проєктне рішення, а не «дрібна правка».

7. Посилання на функції (callable references) :: і відписка

Kotlin дозволяє передавати не лише лямбди, а й посилання на функції через ::. Це теж значення функції, просто отримане не з лямбди, а з уже наявної функції.

Наприклад:

fun onExpenseAdded(e: Expense) {
    println("ОБРОБНИК: ${e.title}") // ОБРОБНИК: Coffee
}

fun main() {
    val service = ExpenseService()

    val handler: (Expense) -> Unit = ::onExpenseAdded
    val unsub = service.expenseAdded.subscribe(handler)

    service.addExpense("Coffee", 250)
    unsub()
}

Тут важлива та сама дисципліна: якщо вам потрібна подальша відписка «за посиланням», зберігайте його у змінній (handler). Якщо ви будете писати subscribe(::onExpenseAdded) у різних місцях і очікувати, що «це одне й те саме посилання», легко заплутатися. Значно спокійніше тримати посилання як значення й не покладатися на випадок.

8. Типові помилки

Помилка №1: намагатися відписатися «такою самою» лямбдою.
Дуже природне бажання: «я ж написав { println(...) }, отже, якщо напишу знову, воно буде тим самим». Ні: це новий об’єкт функції, а отже, remove його не знайде. Якщо ви проєктуєте API подій, токен відписки — найкращий спосіб не змушувати користувача пам’ятати про посилкову ідентичність лямбд.

Помилка №2: не зберігати токен відписки, а потім намагатися «якось вимкнути».
Токен працює лише тоді, коли ви його десь зберегли. Якщо ви підписалися в одному місці, а відписатися хочете в іншому, але токен загубили, ви знову скочуєтеся до «пошуку лямбди». Практика проста: якщо підписка тимчасова, токен зберігається поруч із тим, хто керує життєвим циклом цієї підписки.

Помилка №3: створювати підписки повторно під час кожного ввімкнення функції та отримувати дублікати.
Сценарій «увімкнули аудит» → «увімкнули аудит ще раз» має бути передбачуваним. Якщо кожен enable() робить нову підписку, вивід почне дублюватися, а ви думатимете, що це полтергейст. Зазвичай це лікується тим, що токен зберігається в var unsubscribe. І під час повторного ввімкнення ви не підписуєтеся знову, доки не вимкнули стару підписку.

Помилка №4: очікувати, що відписка впливає на вже розпочату розсилку «прямо зараз».
Наївно хочеться, щоб якщо обробник відписався, він тут же зник із поточного обходу слухачів. У мінімальній реалізації Event така поведінка не гарантується. Вона залежить від того, як написано emit. Поки що ми фіксуємо лише контракт: «після відписки майбутні emit не мають викликати обробник». А стійкість розсилки під час зміни підписок розберемо окремо.

Помилка №5: робити listeners публічним «щоб було простіше відписатися».
Це швидкий шлях до того, що зовнішній код почне видаляти, додавати й перемішувати слухачів як йому заманеться, а Event перетвориться на некеровану купу. Тримайте колекцію слухачів private, а назовні віддавайте лише зрозумілі операції (subscribe, emit і токени відписки).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ