JavaRush /Курсы /Kotlin SELF /Минимальная реализация Even...

Минимальная реализация Event<T>: слушатели, subscribe и emit

Kotlin SELF
57 уровень , 1 лекция
Открыта

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("Got message: $s")
    }

    listener("Hello!") // Got message: Hello!
}

Обратите внимание на две вещи. Во-первых, у лямбды есть параметр s, и это обычная переменная. Во-вторых, возвращаемое значение — Unit, то есть «ничего полезного не возвращаем, просто делаем действие».

Это идеальный формат для обработчика события: событие «передало данные», обработчик «что-то сделал».

2. Минимальный контракт Event<T>

Когда мы говорим «сделаем класс Event<T>», важно не впасть в состояние «сейчас построим свой Spring внутри консольного проекта». Мы сегодня делаем минимум, но так, чтобы минимум был аккуратным и пригодным для расширения (когда придёт время).

Сформулируем контракт человеческим языком. Наш Event<T> должен:

  1. хранить список слушателей (функций типа (T) -> Unit);
  2. позволять подписаться: subscribe(listener);
  3. позволять опубликовать событие: emit(value) — и вызвать всех слушателей по очереди;
  4. быть типобезопасным: если событие Event<ExpenseAdded>, то слушатель обязан принимать ExpenseAdded, а не «что-нибудь».

И ещё важный пункт: список слушателей должен быть спрятан внутрь. Снаружи не должно быть возможности сделать «ой, я случайно очистил listeners». Поэтому список делаем private.

4. Реализация 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): мы добавляем расходы и хотим реагировать на факт добавления.

Сделаем простой payload-тип. В реальном проекте это мог бы быть ваш data class Expense(...), но чтобы пример был компактным, возьмём отдельную «обёртку события».

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

Теперь сделаем сервис, который добавляет расход (пока просто печатает) и эмитит событие:

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

    fun addExpense(title: String, amount: Int) {
        println("Added expense: $title ($amount)")
        expenseAdded.emit(ExpenseAdded(title, amount))
    }
}

Обратите внимание на тонкий момент архитектуры. ExpenseService не делает «логирование», «UI» и «статистику». Он делает свою работу и сообщает факт: «расход добавлен». Всё остальное — дело подписчиков.

7. Подписчики: много реакций на одно событие

Теперь покажем, что событие — это модель «1 → много». Один источник, много слушателей, и они не обязаны знать друг о друге.

fun main() {
    val service = ExpenseService()

    service.expenseAdded.subscribe { e -> println("Audit: ${e.title}") }
    service.expenseAdded.subscribe { e -> println("UI: +${e.amount}") }

    service.addExpense("Coffee", 5)
    // Added expense: Coffee (5)
    // Audit: Coffee
    // UI: +5
}

Здесь приятно то, что addExpense не меняется, когда мы добавляем новые реакции. Хотите третью реакцию? Просто добавляете ещё один subscribe.

Ещё один полезный приём — вынести лямбду в переменную, чтобы она получила имя. Это делает код менее «волшебным»:

fun main() {
    val service = ExpenseService()

    val statsListener: (ExpenseAdded) -> Unit = { e ->
        println("Stats: amount=${e.amount}")
    }

    service.expenseAdded.subscribe(statsListener)
    service.addExpense("Sandwich", 7)
    // Added expense: Sandwich (7)
    // Stats: 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, вы подсознательно превращаете его в команду «отправь email». Это делает архитектуру хуже: источник начинает думать про конкретную реакцию. Лучше называть событие фактом: userSaved, expenseAdded, reportGenerated. Тогда любой обработчик может решать, что делать с этим фактом: хоть письмо, хоть лог, хоть статистику.

Ошибка №4: ожидать, что emit «что-то вернёт» и на это можно опираться.
В нашей модели слушатели возвращают Unit, а emit тоже ничего не возвращает. Это сознательный дизайн: событие — это рассылка факта, а не вычисление результата. Если вам нужно собрать результаты от нескольких обработчиков и агрегировать их — это другой контракт и другая задача. Если пытаться «впихнуть» это в Event<T> сразу, получится неудобный монстр.

Ошибка №5: добавлять в Event лишнюю бизнес-логику.
Иногда хочется, чтобы Event.emit сам писал println("Event happened!"). Это кажется удобным, но быстро превращает Event в место, которое «знает слишком много». Event должен быть инфраструктурным: хранить подписчиков и вызывать их. Если нужен лог — подпишитесь отдельным слушателем (и это как раз хорошая демонстрация, что события работают).

1
Задача
Kotlin SELF, 57 уровень, 1 лекция
Недоступна
Первый слушатель
Первый слушатель
1
Задача
Kotlin SELF, 57 уровень, 1 лекция
Недоступна
Событие сообщений
Событие сообщений
1
Задача
Kotlin SELF, 57 уровень, 1 лекция
Недоступна
Прогресс загрузки
Прогресс загрузки
1
Задача
Kotlin SELF, 57 уровень, 1 лекция
Недоступна
Добавление расхода
Добавление расхода
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ