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» | |
Потрібно зберігати посилання на listener і передавати той самий об’єкт, інакше не видалиться |
| Відписка «за токеном» | |
Потрібно зберігати токен, але він зазвичай простіший і зрозуміліший (одна змінна) |
Якщо один і той самий 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 і токени відписки).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ