CodeGym /Kurse /Kotlin SELF /Minimale Implementierung von

Minimale Implementierung von Event<T>: Listener, subscribe und emit

Kotlin SELF
Level 57, Lektion 1
Verfügbar

1. Einleitung

Wenn Sie gerade erst anfangen, kann die Idee „lasst uns statt eines direkten Aufrufs ein Ereignis machen“ wie eine unnötige Verkomplizierung wirken. Und manchmal stimmt das: Wenn Sie genau einen Handler haben und es immer derselbe ist, ist es einfacher, ihn direkt aufzurufen. Aber sobald mehrere unabhängige Reaktionen auf eine Aktion hinzukommen, machen direkte Aufrufe den Code zum Mähdrescher: Sie fügen println hinzu, dann „noch ein println“, dann „noch eine Funktion“, und plötzlich haben Sie eine Methode mit 200 Zeilen, die gleichzeitig „die Arbeit erledigt“ und „auf die Arbeit reagiert“.

Ein Ereignis hilft, Rollen zu trennen. Die Quelle sagt: „Ich habe eine Ausgabe hinzugefügt.“ Und die Listener entscheiden selbst, was zu tun ist: loggen, UI aktualisieren, Statistik berechnen, Benachrichtigung senden. Die Quelle muss dabei nicht wissen, wer diese Listener sind. Unser Ziel heute ist es, den einfachsten Mechanismus zu implementieren: „Die Quelle hält eine Liste von Funktionen und ruft sie auf“.

Was ist ein Listener in Kotlin: Funktionstyp (T) -> Unit

Bevor wir Event<T> schreiben, müssen wir sehr bodenständig verstehen, was genau wir in der Liste speichern werden. Wir speichern Funktionen. Ja, genauso wie Zahlen, Strings und Listen – nur eben Funktionen.

Dafür gibt es in Kotlin Funktionstypen, zum Beispiel (Int) -> String oder (ExpenseAdded) -> Unit. So ein Typ liest sich fast wie die Signatur einer normalen Funktion: „nimmt T entgegen, gibt Unit zurück“. Kotlin nutzt diese Notation direkt für Variablen, Parameter und Collections von Funktionen.

Hier ist das kleinste Beispiel eines „Listeners“ ohne Ereignisse – einfach eine Funktion in einer Variablen:

fun main() {
    val listener: (String) -> Unit = { s ->
        println("Got message: $s")
    }

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

Beachten Sie zwei Dinge. Erstens: Die Lambda hat einen Parameter s, und das ist eine ganz normale Variable. Zweitens: Der Rückgabewert ist Unit, das heißt: „Wir geben nichts Nützliches zurück, wir führen nur eine Aktion aus.“

Das ist das ideale Format für einen Ereignishandler: Das Ereignis „übermittelt Daten“, der Handler „macht etwas“.

2. Minimaler Vertrag für Event<T>

Wenn wir sagen „wir machen eine Klasse Event<T>“, ist es wichtig, nicht in den Zustand zu verfallen: „Jetzt bauen wir uns Spring im Konsolenprojekt nach.“ Heute machen wir das Minimum, aber so, dass dieses Minimum sauber ist und sich später erweitern lässt (wenn die Zeit dafür kommt).

Formulieren wir den Vertrag in verständlicher Sprache. Unser Event<T> soll:

  1. eine Liste von Listenern speichern (Funktionen vom Typ (T) -> Unit);
  2. das Abonnieren erlauben: subscribe(listener);
  3. das Publizieren eines Ereignisses erlauben: emit(value) – und alle Listener nacheinander aufrufen;
  4. typsicher sein: Wenn das Ereignis Event<ExpenseAdded> ist, dann muss der Listener ExpenseAdded entgegennehmen und nicht „irgendwas“.

Und noch ein wichtiger Punkt: Die Listener-Liste muss nach innen gekapselt sein. Von außen darf es keine Möglichkeit geben, „ups, ich habe versehentlich listeners geleert“. Deshalb machen wir die Liste private.

4. Implementierung von Event<T>: MutableList von Listenern

Jetzt schreiben wir die Klasse selbst. Wir erstellen ein privates Feld listeners und speichern dort Lambdas.

Hier sieht man sehr gut, warum wir vorher Collections und Funktionstypen brauchten: listeners ist buchstäblich 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)
    }
}

In diesen drei Zeilen steckt viel Bedeutung:

In listeners += listener wird der Operator += verwendet, der für MutableList ein Element „in-place“ hinzufügt (das ist plusAssign). Das ist einfach eine kürzere Schreibweise als listeners.add(listener).

In emit laufen wir per Schleife durch die Liste und rufen jeden Handler auf: l(value). Das sieht wie ein „Funktionsaufruf“ aus, weil l eine Variable eines Funktionstyps ist; man kann sie wie eine normale Funktion aufrufen.

Wenn es keine Abonnenten gibt, wird die Schleife einfach nicht ausgeführt. Das ist ein normaler Vertrag: Das Ereignis „läuft still durch“.

5. Ereignisdaten: warum der Typ T nötig ist

Der Typparameter T ist das, was unser Event wirklich bequem macht. Er legt fest, welche Daten das Ereignis transportiert.

Man könnte ein „universelles“ Ereignis auf Basis von Any machen und dort alles Mögliche hineinreichen, aber dann würden Sie sofort die Hälfte des Nutzens von Kotlin verlieren: Der Compiler würde Sie nicht mehr vor Fehlern schützen.

In Kotlin ist die Idee „der Funktionstyp enthält den Parametertyp“ grundlegend. Wenn Sie val f: (Int) -> Unit haben, können Sie dort nicht aus Versehen einen String übergeben. Genau so ist es hier: Wenn das Ereignis Event<ExpenseAdded> ist, muss der Listener ExpenseAdded entgegennehmen. Das ist „Typsicherheit“.

Übrigens wird das in der Kotlin-Dokumentation als „function types“ beschrieben: Die Schreibweise (A, B) -> C entspricht Funktionen, die Argumente der Typen A, B annehmen und C zurückgeben.

6. Beispiel: Ereignis „Ausgabe hinzugefügt“

Damit das Beispiel nicht im luftleeren Raum hängt, führen wir unser Lern-Konsolenprogramm fort (nennen wir es der Einfachheit halber BudgetBook): Wir fügen Ausgaben hinzu und wollen auf die Tatsache der Hinzufügung reagieren.

Wir machen einen einfachen Payload-Typ. In einem echten Projekt könnte das Ihr data class Expense(...) sein, aber damit das Beispiel kompakt bleibt, nehmen wir eine separate „Ereignis-Wrapper“-Klasse.

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

Jetzt bauen wir einen Service, der eine Ausgabe hinzufügt (vorerst nur ausgibt) und das Ereignis emittiert:

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

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

Beachten Sie einen subtilen Architekturpunkt. ExpenseService macht kein „Logging“, keine „UI“ und keine „Statistik“. Er erledigt seine Aufgabe und meldet die Tatsache: „Ausgabe hinzugefügt“. Alles andere ist Sache der Abonnenten.

7. Abonnenten: viele Reaktionen auf ein Ereignis

Jetzt zeigen wir, dass ein Ereignis ein „1 → viele“-Modell ist. Eine Quelle, viele Listener, und sie müssen nichts voneinander wissen.

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
}

Das Schöne hier: addExpense ändert sich nicht, wenn wir neue Reaktionen hinzufügen. Sie wollen eine dritte Reaktion? Fügen Sie einfach noch ein subscribe hinzu.

Ein weiterer nützlicher Trick ist, die Lambda in eine Variable auszulagern, damit sie einen Namen bekommt. Das macht den Code weniger „magisch“:

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. Einschränkungen der Minimalversion und Interaktionsschema

Wichtig ist, es ehrlich zu sagen: Diese Version von Event<T> ist minimal. Sie löst die Hauptaufgabe: „Wir speichern eine Liste von Handlern und rufen sie auf.“ Aber sie enthält einige bewusste Vereinfachungen.

Erstens lösen wir die Frage „wie meldet man sich ab?“ nicht. Im Moment ist das Abonnement „für immer“ (innerhalb der Lebensdauer des Event-Objekts). Das ist kein Bug, sondern einfach ein offenes Thema: Wir verkomplizieren das Interface heute absichtlich nicht, damit Sie die grundlegende Mechanik spüren.

Zweitens bauen wir keinen Schutz dagegen ein, dass ein Handler eine Exception werfen kann. Wenn jetzt ein Listener abstürzt, wird die Ausführung von emit bei ihm abgebrochen, und die restlichen Listener werden möglicherweise nicht ausgeführt. In manchen Szenarien ist das sogar erwünscht (kritischer Fehler), aber häufiger möchte man, dass ein abgestürzter Handler nicht den gesamten Versand kaputtmacht. Das ist jedoch bereits eine Frage der „Zuverlässigkeit des Versands“, und die implementieren wir heute nicht, um nicht zwei Themen zu einem Brei zu vermischen.

Drittens diskutieren wir keine „gefährlichen“ Fälle, in denen ein Handler während des Versands die Listener-Liste verändert. Das ist eine eigene Engineering-Geschichte: wichtig, aber für ein minimales Ereignis ist es noch zu früh.

Unser heutiges Ziel ist, dass Sie klar verstehen: Event<T> ist nur ein kleines Objekt mit einer MutableList von Funktionen und zwei Methoden.

Kleines Schema: wer mit wem spricht

Manchmal ist es hilfreicher, Architektur mit den Augen zu sehen, als noch einen Absatz zu lesen. In der Minimalform sieht das so aus:

flowchart LR
    S["ExpenseService
(Quelle der Tatsache)"] -->|"emit(ExpenseAdded)"| E["Event⟨ExpenseAdded⟩"] E --> L1["Audit listener
(Log/Audit)"] E --> L2["UI listener
(Meldung an den Nutzer)"] E --> L3["Stats listener
(Statistik)"]

Und beachten Sie: Pfeile „Listener → Service“ gibt es hier nicht. Der Service muss überhaupt nicht wissen, dass Abonnenten existieren. Genau das schafft lose Kopplung: Die Quelle meldet die Tatsache, und Reaktionen werden von außen angeschlossen.

9. Typische Fehler

Fehler Nr. 1: die Listener-Liste public machen, „damit es bequem ist“.
Fast immer endet das damit, dass jemand von außen event.listeners.clear() aufruft oder dort etwas auf eine Weise hinzufügt, die Sie nicht erwarten. Dadurch verhält sich das Ereignis unvorhersehbar, und Sie fangen Bugs vom Typ „warum werden meine Handler manchmal nicht aufgerufen“. Die Liste muss private sein, und nach außen sollten nur die Methoden subscribe und emit sichtbar sein.

Fehler Nr. 2: Listener als Any speichern (oder () -> Unit für alle Fälle).
Es wirkt, als sei „universeller“ gleich „besser“. In der Praxis verlieren Sie Typsicherheit und schleppen Casts, Typprüfungen und anderen Schmerz aus der Welt dynamischer Sprachen mit. Die Stärke von Kotlin ist gerade, dass (T) -> Unit den Vertrag des Handlers festlegt und Event<T> den Vertrag des Ereignisses fixiert. Funktionstypen sind ein normaler und unterstützter Sprachmechanismus.

Fehler Nr. 3: „Ereignis“ und „Kommando“ verwechseln.
Wenn Sie ein Ereignis sendEmailEvent nennen, verwandeln Sie es unbewusst in ein Kommando „sende eine E-Mail“. Das verschlechtert die Architektur: Die Quelle beginnt, an eine konkrete Reaktion zu denken. Besser ist es, das Ereignis nach einer Tatsache zu benennen: userSaved, expenseAdded, reportGenerated. Dann kann jeder Handler selbst entscheiden, was er mit dieser Tatsache macht: E-Mail, Log oder Statistik.

Fehler Nr. 4: erwarten, dass emit „etwas zurückgibt“, auf das man sich verlassen kann.
In unserem Modell geben Listener Unit zurück, und emit gibt ebenfalls nichts zurück. Das ist ein bewusstes Design: Ein Ereignis ist ein Versand einer Tatsache, keine Ergebnisberechnung. Wenn Sie Ergebnisse mehrerer Handler einsammeln und aggregieren müssen, ist das ein anderer Vertrag und eine andere Aufgabe. Wenn man versucht, das sofort in Event<T> „hineinzupressen“, entsteht ein unhandliches Monster.

Fehler Nr. 5: unnötige Business-Logik in Event einbauen.
Manchmal möchte man, dass Event.emit selbst println("Event happened!") schreibt. Das wirkt bequem, macht aus Event aber schnell einen Ort, der „zu viel weiß“. Event soll Infrastruktur sein: Abonnenten speichern und aufrufen. Wenn Sie Logging wollen – abonnieren Sie einen separaten Listener (und das ist genau eine gute Demonstration dafür, dass Ereignisse funktionieren).

1
Aufgabe
Kotlin SELF, Level 57, Lektion 1
Gesperrt
Erster Listener
Erster Listener
1
Aufgabe
Kotlin SELF, Level 57, Lektion 1
Gesperrt
Nachrichtenereignis
Nachrichtenereignis
1
Aufgabe
Kotlin SELF, Level 57, Lektion 1
Gesperrt
Ladefortschritt
Ladefortschritt
1
Aufgabe
Kotlin SELF, Level 57, Lektion 1
Gesperrt
Ausgabe hinzufügen
Ausgabe hinzufügen
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION