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("Added: $e") // Added: Expense(title=Coffee, amount=250)
expenseAdded.emit(e)
}
}
Пока всё красиво. Но дальше начинается «жизнь»: аудит мы хотим включать/выключать, сообщения в консоль — тоже не всегда, а тестовый обработчик вообще должен быть временным. Значит, нам нужна нормальная отписка.
2. Почему “просто удалить слушатель” сложнее, чем кажется
Когда у нас есть список, кажется логичным: «ну удалю из списка — и всё». Проблема в том, что слушатель — это функция, а в Kotlin функции можно хранить как значения (лямбды, ссылки на функции). Это прям отдельная суперсила языка: функции можно складывать в переменные, коллекции, передавать как параметры и возвращать из функций.
Но как удалить функцию из списка? Нужно точно знать, какую именно функцию вы добавляли. И здесь нас ждёт классическая ловушка новичка: «я же напишу такую же лямбду ещё раз».
Смотрите, как выглядит «наивная попытка»:
val event = Event<String>()
fun main() {
event.subscribe { println("Got: $it") }
// Мы пытаемся "отписаться" похожей лямбдой…
// Но такой функции в нашем API даже нет, и это не случайно.
}
Даже если бы мы сделали метод unsubscribe(listener), вы бы очень быстро узнали, что «та же самая лямбда по тексту» — это не «та же самая лямбда по ссылке». В программировании это как пытаться вернуть в магазин «точно такую же» футболку, но не ту, которую вы купили: выглядит похоже, а чек не совпадает.
3. Функции как значения и “ссылочность” лямбд
Что именно мы кладём в listeners
В Kotlin у функции есть тип. Например, обработчик, который принимает Expense и ничего не возвращает, имеет тип (Expense) -> Unit. Это называется тип функции; Kotlin прямо использует такую запись для описания «функций как значений».
Давайте чуть «приземлим» это на простой пример, чтобы мозг не пытался сбежать в отпуск:
fun main() {
val printer: (String) -> Unit = { text ->
println("PRINT: $text")
}
printer("Hello") // PRINT: Hello
}
Здесь 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("Hi") }
val b: () -> Unit = { println("Hi") }
println(a == b) // false
println(a === b) // false
}
Почему так? Потому что в большинстве случаев лямбда — это объект, а новый литерал лямбды — новый объект. Если вы хотите удалить «тот же обработчик», вам нужна ссылка на него: та же переменная, тот же объект.
fun main() {
val a: () -> Unit = { println("Hi") }
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("Got: $text")
}
event.emit("A") // Got: 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: added ${e.title} for ${e.amount}") // AUDIT: added Coffee for 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("Got: $it") }
val u1 = e.subscribe(listener)
val u2 = e.subscribe(listener)
e.emit("X")
// Got: X
// Got: X
u1()
e.emit("Y")
// Got: Y (одна подписка ещё осталась)
u2()
e.emit("Z")
// (тишина)
}
Такое поведение логично, если думать «это список подписок», а не «множество уникальных обработчиков». Главное — считать это частью контракта и не удивляться. Если когда-нибудь вам понадобится другая политика (например, запрет дубликатов), это будет отдельное проектное решение, а не «мелкая правка».
7. Callable references :: и отписка
Kotlin позволяет передавать не только лямбды, но и ссылки на функции через ::. Это тоже значение функции, просто полученное не из лямбды, а из существующей функции.
Например:
fun onExpenseAdded(e: Expense) {
println("HANDLER: ${e.title}") // HANDLER: 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) в разных местах и ожидать, что «это одна и та же ссылка», можно попасть в путаницу — гораздо спокойнее держать ссылку как значение и не полагаться на удачу.
9. Типичные ошибки
Ошибка №1: пытаться отписаться “такой же” лямбдой.
Очень естественное желание: «я же написал { println(...) }, значит, если напишу снова, оно будет тем же самым». Нет: это новый объект функции, а значит, remove его не найдёт. Если вы проектируете API событий, токен отписки — лучший способ не заставлять пользователя помнить про ссылочную идентичность лямбд.
Ошибка №2: не сохранять токен отписки, а потом пытаться “как-нибудь отключить”.
Токен работает только если он у вас где-то лежит. Если вы подписались в одном месте, а отписаться хотите в другом, но токен потеряли, вы снова скатываетесь к «поиску лямбды». Практика простая: если подписка временная, токен хранится рядом с тем, кто управляет жизненным циклом этой подписки.
Ошибка №3: создавать подписки повторно при каждом включении функции и получать дубли.
Сценарий «включили аудит» → «включили аудит ещё раз» должен быть предсказуемым. Если каждый enable() делает новую подписку, вывод начнёт дублироваться, а вы будете думать, что это полтергейст. Обычно это лечится тем, что токен хранится в var unsubscribe, и при повторном включении вы не подписываетесь заново, пока не отключили старую подписку.
Ошибка №4: ожидать, что отписка влияет на уже начавшуюся рассылку “прямо сейчас”.
Наивно хочется, чтобы если обработчик отписался, он тут же исчез из текущего обхода слушателей. В минимальной реализации Event это поведение не гарантируется и зависит от того, как написан emit. Пока что мы фиксируем только контракт «после отписки будущие emit не должны вызывать обработчик», а устойчивость рассылки при изменении подписок разберём отдельно.
Ошибка №5: делать listeners публичным “чтобы было проще отписаться”.
Это быстрый путь к тому, что внешний код начнёт удалять, добавлять и перемешивать слушателей как ему вздумается, а Event превратится в неуправляемую кучу. Держите коллекцию слушателей private, а наружу отдавайте только понятные операции (subscribe, emit и токены отписки).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ