1. Вступ
Якщо ви тільки починаєте, ідея «давайте замість прямого виклику зробимо подію» може здаватися зайвим ускладненням. Іноді так і є: якщо у вас один обробник і він завжди той самий, простіше викликати його безпосередньо. Але щойно зʼявляються кілька незалежних реакцій на одну дію, прямі виклики швидко перетворюють код на важкий «комбайн». Ви додаєте println, потім «ще один println», потім «ще одну функцію» — і раптом маєте метод на 200 рядків, який одночасно «робить справу» й «реагує на справу».
Подія допомагає розділити ролі. Джерело каже: «Я додав витрату». А слухачі вже самі вирішують, що робити: логувати, оновлювати інтерфейс, рахувати статистику, надсилати сповіщення. При цьому джерело не зобовʼязане знати, хто ці слухачі. Наша мета сьогодні — реалізувати найпростіший механізм: «джерело зберігає список функцій і викликає їх».
Хто такий слухач у Kotlin: тип функції (T) -> Unit
Перш ніж писати Event<T>, потрібно дуже приземлено зрозуміти, що саме ми зберігатимемо в списку. Ми зберігатимемо функції. Так, буквально як числа, рядки та списки — тільки функції.
У Kotlin для цього є типи функцій, наприклад (Int) -> String або (ExpenseAdded) -> Unit. Такий тип читається майже як підпис звичайної функції: «приймає T, повертає Unit». Kotlin безпосередньо використовує цю нотацію для змінних, параметрів і колекцій функцій.
Ось найменший приклад «слухача» без подій — просто функція в змінній:
fun main() {
val listener: (String) -> Unit = { s ->
println("Отримано повідомлення: $s")
}
listener("Hello!") // Отримано повідомлення: Hello!
}
Зверніть увагу на дві речі. По-перше, у лямбди є параметр s — і це звичайна змінна. По-друге, значення, що повертається, — Unit, тобто «нічого корисного не повертаємо, просто виконуємо дію».
Це ідеальний формат для обробника події: подія «передала дані», обробник «щось зробив».
2. Мінімальний контракт Event<T>
Коли ми кажемо «зробимо клас Event<T>», важливо не впасти в стан «зараз побудуємо свій Spring усередині консольного проєкту». Сьогодні ми робимо мінімум — але так, щоб він був охайним і придатним для розширення (коли настане час).
Сформулюймо контракт людською мовою. Наш Event<T> має:
- зберігати список слухачів (функцій типу (T) -> Unit);
- дозволяти підписатися: subscribe(listener);
- дозволяти опублікувати подію: emit(value) — і викликати всіх слухачів по черзі;
- бути типобезпечним: якщо подія Event<ExpenseAdded>, то слухач зобовʼязаний приймати ExpenseAdded, а не «що-небудь».
І ще один важливий пункт: список слухачів має бути схований усередині. Ззовні не повинно бути можливості зробити «ой, я випадково очистив listeners». Тому список робимо private.
3. Реалізація Event<T>: MutableList слухачів
Тепер напишімо сам клас. Ми створимо приватне поле listeners і зберігатимемо там лямбди.
Тут добре видно, навіщо нам раніше були потрібні колекції й типи функцій: listeners — це буквально MutableList<(T) -> Unit>.
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)
}
}
Тут у трьох рядках заховано багато змісту:
У listeners += listener використовується оператор +=, який для MutableList додає елемент «на місці» (це plusAssign). Це просто коротший запис, ніж listeners.add(listener).
У emit ми проходимо циклом по списку й викликаємо кожен обробник: l(value). Це виглядає як «виклик функції», тому що l — змінна функціонального типу, тож її можна викликати як звичайну функцію.
Якщо підписників немає, цикл просто не виконається. Це нормальний контракт: подія «мовчки минає».
5. Дані події: навіщо потрібен тип T
Параметр типу T — це те, що робить наш Event справді зручним. Він фіксує, які дані несе подія.
Можна було б зробити «універсальну» подію на Any і передавати туди що завгодно. Але тоді ви одразу втратили б половину користі Kotlin: компілятор перестав би захищати вас від помилок.
У Kotlin ідея «тип функції містить тип параметра» — базова. Якщо у вас є val f: (Int) -> Unit, ви не зможете випадково передати туди рядок. Те саме й тут: якщо подія Event<ExpenseAdded>, слухач зобовʼязаний приймати ExpenseAdded. Це і є «типобезпечність».
До речі, у документації Kotlin це описують як «function types»: запис (A, B) -> C відповідає функціям, що приймають аргументи типів A, B і повертають C.
6. Приклад: подія «витрату додано»
Щоб приклад не був у вакуумі, продовжмо наш навчальний консольний застосунок (умовно назвемо його BudgetBook): ми додаємо витрати й хочемо реагувати на сам факт додавання.
Зробімо простий тип даних події. У реальному проєкті це міг би бути ваш data class Expense(...), але, щоб приклад був компактним, візьмемо окрему «обгортку події».
data class ExpenseAdded(val title: String, val amount: Int)
Тепер зробімо сервіс, який додає витрату (поки що просто друкує) і викликає подію через emit:
class ExpenseService {
val expenseAdded = Event<ExpenseAdded>()
fun addExpense(title: String, amount: Int) {
println("Додано витрату: $title ($amount)")
expenseAdded.emit(ExpenseAdded(title, amount))
}
}
Зверніть увагу на тонкий архітектурний момент. ExpenseService не займається «логуванням», «UI» і «статистикою». Він робить свою роботу й повідомляє факт: «витрату додано». Усе інше — справа підписників.
7. Підписники: багато реакцій на одну подію
Тепер покажімо, що подія — це модель «1 → багато». Одне джерело, багато слухачів — і вони не зобовʼязані знати один про одного.
fun main() {
val service = ExpenseService()
service.expenseAdded.subscribe { e -> println("Аудит: ${e.title}") }
service.expenseAdded.subscribe { e -> println("UI: +${e.amount}") }
service.addExpense("Кава", 5)
// Додано витрату: Кава (5)
// Аудит: Кава
// UI: +5
}
Тут приємно те, що addExpense не змінюється, коли ми додаємо нові реакції. Хочете третю реакцію? Просто додаєте ще один subscribe.
Ще один корисний прийом — винести лямбду в змінну, щоб вона отримала імʼя. Так код виглядає менш «магічним»:
fun main() {
val service = ExpenseService()
val statsListener: (ExpenseAdded) -> Unit = { e ->
println("Статистика: amount=${e.amount}")
}
service.expenseAdded.subscribe(statsListener)
service.addExpense("Сендвіч", 7)
// Додано витрату: Сендвіч (7)
// Статистика: amount=7
}
8. Обмеження мінімальної версії та схема взаємодії
Важливо чесно сказати: ця версія Event<T> — мінімальна. Вона розвʼязує головне завдання: «зберігаємо список обробників і викликаємо їх». Але в ній є кілька свідомих спрощень.
По-перше, ми не розвʼязуємо питання «а як відписатися». Поки що підписка «назавжди» (у межах життя обʼєкта Event). Це не баг, а просто тема, яку ми ще не закрили. Ми спеціально не ускладнюємо інтерфейс сьогодні, щоб ви відчули базову механіку.
По-друге, ми не робимо жодного захисту від того, що обробник може кинути виняток. Зараз, якщо один слухач «упаде», виконання emit зупиниться саме на ньому — і решта слухачів може не виконатися. У деяких сценаріях це навіть бажано (помилка критична), але частіше хочеться, щоб один проблемний обробник не ламав усю розсилку. Утім, це вже питання надійності розсилки, і сьогодні ми його не реалізуємо, щоб не змішувати дві теми в одну кашу.
По-третє, ми не обговорюємо «небезпечні» випадки, коли обробник під час розсилки змінює список слухачів. Це окрема інженерна історія: вона важлива, але для мінімальної події поки що рано.
Наша мета на сьогодні — щоб ви чітко розуміли: Event<T> — це лише маленький обʼєкт із MutableList функцій і двома методами.
Невелика схема: хто з ким розмовляє
Іноді корисніше побачити архітектуру, ніж читати ще один абзац. У мінімальному вигляді це виглядає так:
flowchart LR
S["ExpenseService
(джерело факту)"] -->|"emit(ExpenseAdded)"| E["Event⟨ExpenseAdded⟩"]
E --> L1["Audit listener
(лог/аудит)"]
E --> L2["UI listener
(повідомлення користувачу)"]
E --> L3["Stats listener
(статистика)"]
І зверніть увагу: стрілок «слухач → сервіс» тут немає. Сервіс узагалі не зобовʼязаний знати, що підписники існують. Саме це й дає слабку звʼязаність: джерело повідомляє факт, а реакції підʼєднуються ззовні.
9. Типові помилки
Помилка №1: робити список слухачів публічним, «щоб було зручно».
Майже завжди це закінчується тим, що хтось ззовні робить event.listeners.clear() або додає туди щось не тим способом, на який ви очікуєте. У результаті подія починає поводитися непередбачувано, і ви ловите баги рівня «чому в мене інколи не викликаються обробники». Список має бути private, а назовні повинні «стирчати» тільки методи subscribe і emit.
Помилка №2: зберігати слухачів як Any (або () -> Unit для всіх випадків).
Здається, що «універсальніше» означає «краще». На практиці ви втрачаєте типобезпечність і починаєте тягнути перетворення типів, перевірки та інший біль зі світу динамічних мов. Сила Kotlin якраз у тому, що (T) -> Unit фіксує контракт обробника, а Event<T> фіксує контракт події. Типи функцій — нормальний і підтримуваний механізм мови.
Помилка №3: плутати «подію» і «команду».
Якщо ви називаєте подію sendEmailEvent, ви підсвідомо перетворюєте її на команду «надішли лист». Це погіршує архітектуру: джерело починає думати про конкретну реакцію. Краще називати подію фактом: userSaved, expenseAdded, reportGenerated. Тоді будь-який обробник зможе вирішувати, що робити з цим фактом: хоч лист, хоч лог, хоч статистику.
Помилка №4: очікувати, що emit «щось поверне» і на це можна спиратися.
У нашій моделі слухачі повертають Unit, а emit теж нічого не повертає. Це свідомий дизайн: подія — це розсилка факту, а не обчислення результату. Якщо вам потрібно зібрати результати від кількох обробників і агрегувати їх, це інший контракт і зовсім інша задача. Якщо намагатися «впхнути» це в Event<T> одразу, вийде незручний монстр.
Помилка №5: додавати в Event зайву бізнес-логіку.
Іноді хочеться, щоб Event.emit сам писав println("Подія сталася!"). Це здається зручним, але швидко перетворює Event на місце, яке «знає надто багато». Event має бути інфраструктурним: зберігати підписників і викликати їх. Якщо потрібен лог — підпишіться окремим слухачем (і це якраз хороша демонстрація того, що події працюють).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ