Blog

Alejandro Serrano Mena

Alejandro Serrano Mena

Aktualisiert Oktober 15, 2025
14 Minuten

Der "Aussetzen + Empfänger"-Stil

Das Arrow-Projekt, insbesondere ab der Version 2.0, fördert einen bestimmten Stil von Kotlin, um die Kompatibilität von Effekten zu erreichen. Viele Konzepte sind von funktionalen Programmiermustern inspiriert, aber die konkrete Umsetzung unterscheidet sich von der typischen Vorgehensweise in Haskell oder Scala (Monaden, IO und Transformatoren). In diesem Beitrag wollen wir klären, wie dieser Stil in Kotlin funktioniert, ihn mit anderen Geschwistersprachen vergleichen und die wichtigsten Einschränkungen diskutieren.

In diesem Beitrag wird davon ausgegangen, dass der Leser mit dem Transformer-Stil von Effekten vertraut ist. Gute Quellen zum Verständnis dieses Stils sind Scala with Cats, Practical FP in Scala oder das Book of Monads.

Warnung: Kontext-Empfänger sind noch experimentell, gemäß der 1.7.x Serie. Es gibt noch ein paar Ecken und Kanten, wenn Sie sie mit suspend und inline mischen, aber das Kotlin-Team unternimmt offensichtlich große Anstrengungen, um sie stabil zu machen.

Auswirkungen

Bevor wir in den technischen Teil eintauchen, lassen Sie uns besprechen, was wir erreichen wollen, wenn wir über Effekte sprechen. Die Idee ist, das sichtbare Verhalten einer Funktion in ihrer Signatur explizit anzugeben, da dies leistungsfähigere Compilerprüfungen und -analysen ermöglicht. Standardmäßig ist dies nur bei reinen Funktionen der Fall, was eine schicke Umschreibung für "Funktionen, die nur Berechnungen ausführen" ist. Nehmen Sie die folgende Funktion:

fun add(x: Int, y: Int): Int = x + y

Hier sind die Typen "ehrlich"; wir erhalten immer eine Zahl zurück, und diese Zahl hängt nur von den Eingabeargumenten ab. In Kotlin können wir leicht andere Verhaltensweisen einbauen, die wir oft als Seiteneffekte bezeichnen. Wir könnten einen Wert ausgeben,

fun loggingAdd(x: Int, y: Int): Int {
  println("x = $x, y = $y")
  return x + y
}

oder, noch weitergehend, rollen Sie die Ausführungsspur mit einer Ausnahme zurück, aber nur zu bestimmten Zeitpunkten.

fun crazyAdd(x: Int, y: Int): Int {
  if (Random.nextInt() == 42) throw WhatsHappeningException()
  return x + y
}

Das Ziel ist es, diese Effekte zu beleuchten und das potenzielle Verhalten direkt sichtbar zu machen. Eine Funktion wie die letztgenannte bringt einen Typ näher an

context(Raise<WhatsHappeningError>, Random)
fun crazyAdd(x: Int, y: Int): Int

Dies ist wieder ein extremes Beispiel, aber stellen Sie sich diese Auswirkungen als Netzwerkkommunikation, Zugriff auf das Dateisystem oder, genauer gesagt, als Lesen der Anfangskonfiguration oder Verwendung des Repository-Objekts für einen Teil der Domäne vor. Die folgende Signatur sagt ganz klar, dass die Funktion einen Wert zurückgeben kann oder auch nicht (wegen des nullbaren Rückgabetyps), dass sie das Benutzer-Repository verwenden kann (wir erwarten also eine Art von Datenbankzugriff) und dass die Möglichkeit eines Fehlers besteht (was etwas anderes ist als ein nicht vorhandener Wert).

context(Raise<WhatsHappeningError>, UserRepository)
suspend fun getUserById(id: UserId): User?

Die Frage ist: Wie kodieren wir diese Effekte mit Hilfe des Typsystems? Hier unterscheiden sich die Wege von Kotlin und Haskell / Scala erheblich.

Zwei integrierte Kotlin-Funktionen

Um Effekte zu modellieren und performant auszuführen, empfehlen wir, Coroutines und Kontextempfänger in Kotlin ausgiebig zu verwenden. In diesem Abschnitt werden die einzelnen Funktionen kurz vorgestellt und ihre Rolle im Gesamtmuster erörtert.

Coroutines sind aussetzbare Funktionen. Aus der Sicht des Entwicklers sind diese Funktionen mit dem Schlüsselwort suspend gekennzeichnet:

suspend fun getUserById(id: UserId): User?

Die Sprache Kotlin weiß, wie man mehrere unterbrochene Funktionen ohne Eingreifen des Entwicklers kombiniert. Mit anderen Worten: Coroutines sind für Entwickler transparent, abgesehen von der Anforderung, Funktionen, die sie verwenden, zu markieren. Wir können sogar suspendierte Funktionen mit regulären Funktionen mischen, wie hier beim Zugriff auf das Feld .name.

suspend fun getUserName(id: UserId): String? =
  getUserById(id)?.name

Obwohl Coroutines oft zusammen mit den Gleichzeitigkeitsmechanismen von kotlinx.coroutines, das sind zwei verschiedene Ideen. Coroutines ermöglichen eine feinkörnige Kontrolle über die Berechnungen innerhalb eines suspend Blocks. Gleichzeitigkeit ist eine Form, diese Kontrolle auszuüben, indem entschieden wird, wann und in welchen Threads Berechnungen stattfinden. Aber das ist nicht die einzige:

  • Inikio ist eine Bibliothek, die suspend nutzt, um eine "imperative" Syntax für domänenspezifische Sprachen zu erstellen;
  • Arrow bietet Computation Builder, mit denen Sie Funktionen über Either, Result oder löschbare Typen schreiben können, ohne bei jedem Schritt auf den Fehlerpfad prüfen zu müssen.

Die Möglichkeit, die Berechnung auf diese Weise zu steuern, ergibt sich aus der Umwandlung, die der Compiler in continuation-passing Stil . Langer Rede kurzer Sinn, unsere obigen Funktionen nehmen in Wirklichkeit ein zusätzliches Argument - die Fortsetzung - die mit dem Ergebnis der Funktion "gefüttert" wird.

fun getUserById(id: UserId, k: Continuation<User?>)

fun getUserName(id: UserId, k: Continuation<String?>) =
  getUserById(id) { user -> user?.let { k.resume(it.name) } }

Auf einer konzeptionellen Ebene können Sie sich Continuation<A> einfach als eine Funktion (A) -> Unit vorstellen. Das bedeutet, dass der Aufrufer ändern kann, wie die Funktion "zurückkehrt", aber die als Parameter übergebene Fortsetzung k ändern kann.

Die zweite Funktion in der Sprache Kotlin, die für den vorgeschlagenen Stil erforderlich ist, sind Kontextempfänger, die in Version 1.6.20 eingeführt wurden. Eine ausführliche Diskussion dieser neuen Funktion finden Sie in diesem Vortrag oder den entsprechenden Folien. Ganz kurz gesagt, kann man sich Kontextempfänger als implizite Parameter oder als eine Art eingebaute Dependency Injection vorstellen. Nehmen wir an, wir definieren eine Schnittstelle für unser Benutzer-Repository,

interface UserRepository {
  suspend fun getUserById(id: UserId): User?
}

Dann können wir dieses Repository in einer anderen Funktion verfügbar machen, indem wir es als Teil der context Deklaration angeben.

context(UserRepository)
suspend fun getUserName(id: UserId): String? =
  getUserById(id)?.name

Diese Signatur könnte auch als suspend context(UserRepository) fun geschrieben werden, aber wir haben uns entschieden, die Kontexte zuerst zu schreiben, um sie hervorzuheben.

Das obige Beispiel zeigt, dass Sie, wenn Sie ein UserRepository in Ihrem context haben, jede andere Funktion, die diesen Kontext benötigt - hier getUserById - ohne weitere Zeremonie aufrufen können. Diese Kompositionsfähigkeit lässt sich beliebig erweitern; die getUserName kann wiederum von einer anderen Funktion mit demselben Kontext aufgerufen werden. An einem bestimmten Punkt müssen wir jedoch die eigentliche Implementierung injizieren; dies geschieht mit der Funktion with,

fun main() {
  createDbConnection().use { db ->
    with(DbUserRepository(db)) {
      println(getUserName(UserId(1)))
    }
  }
}

In diesem Fall stellt die Implementierung eine Verbindung zu einer Datenbank her, aber wir könnten zum Testen auch ein anderes In-Memory-Objekt injizieren. An dieser Stelle kommt die Idee der Dependency Injection ins Spiel.

Kontext-Empfänger sind eine Verallgemeinerung eines älteren Kotlin-Features namens Erweiterungs-Empfänger. Obwohl ihre ursprüngliche Verwendung darin bestand, die Funktionalität einer Klasse zu "erweitern" und dabei die Syntax für Zugriffsmitglieder beizubehalten, wurden sie auch verwendet, um Bereiche zu beschreiben, in denen bestimmte zusätzliche Funktionen verfügbar sind. Hier ist die (vereinfachte) Signatur des async Funktion, die eine Berechnung in einem neuen leichtgewichtigen Thread auslöst:

fun <T> CoroutineScope.async(block: suspend CoroutineScope.() -> T): Deferred<T>

Diese Signatur besagt, dass async nur innerhalb eines CoroutineScope -Blocks aufgerufen werden darf und dass die durch das block -Argument repräsentierte gespawnte Berechnung auch Zugang zu einem anderen CoroutineScope erhält, so dass sie selbst andere Jobs starten kann. Wenn wir die Signatur mit Hilfe von Kontextempfängern "modernisieren",

context(CoroutineScope)
fun <T> async(block: context(CoroutineScope) suspend () -> T): Deferred<T>

können wir sehen, dass diese Bereiche nichts anderes als Effekte sind! Dieser Gedanke ist an mehreren Stellen in der Arrow-Bibliothek zu finden:

  • A ResourceScope bringt die Fähigkeit mit, Ressourcen korrekt zu erwerben und freizugeben.
  • STM führt das Konzept der transaktionalen Variablen ein, deren gleichzeitiger Zugriff geschützt ist.

Um ihre Aufgabe zu erfüllen, benötigen resourceScope (der "Läufer" für ResourceScope) und atomically (der "Läufer" für STM) die Kontrolle über die Berechnung. Genau hier wird das Coroutine-System notwendig.

Die Betonung von Kontextempfängern liegt auf ihrer Fähigkeit, im Gegensatz zu den anderen Erweiterungsempfängern zu komponieren. Wenn eine Funktion sowohl die Handhabung von Ressourcen als auch ein Benutzer-Repository erfordert, können wir dies leicht mit dem ersteren ausdrücken

context(UserRepository, ResourceScope)
fun whoKnowsWhatItDoes(name: String)

aber bei letzterem gibt es keinen (einfachen) Weg. Wir hoffen, dass diese Kompositionsfähigkeit die Entwickler dazu ermutigt, mehr und feinere Scopes/Effekte zu erstellen, um das Verhalten der Funktionen so konkret wie möglich zu gestalten.

Keine Monaden, keine höherwertigen Typen

Traditionell wurde die Möglichkeit, die Ausführung auf ähnliche Weise zu steuern, wie es das Coroutine-System in Kotlin bietet, von einer monadischen Schnittstelle angeboten. Konkret stellen Monaden eine Funktion zur Verfügung, mit der eine Abfolge von Schritten mit dem nächsten auszuführenden Schritt kombiniert werden kann. Diese Operation ist bekannt als flatMap oder bind (>>= wenn Sie ein Haskeller sind).

fun <A, B> M<A>.flatMap(next: (A) -> M<B>): M<B>

Da der Implementierer jeder einzelnen Monade das Verhalten von flatMap kontrolliert, kann jede zusätzliche Kontrolle an diesem Punkt eingefügt werden. Zum Beispiel schließt die Nullbarkeitsmonade jedes andere Verhalten kurz, wenn eine null in einem der Schritte erzeugt wird.

fun <A, B> A?.flatMap(next: (A) -> B?): B? = 
  when (this) {
    null -> null
    else -> next(this)
  }

Eine wichtige Monade sowohl für Haskell als auch für Scala ist die IO Monade, die den Code als "unrein" kennzeichnet, d.h. als einen Code mit potenziellen Seiteneffekten wie z.B. das Schreiben oder Lesen von einem Gerät. Beachten Sie, dass es im Fall von Scala keine einheitliche Monade gibt; wir haben Cats Effect und ZIO als prominente Beispiele. In Kotlin [kann dasselbe mit suspend] erreicht werden; um eine angehaltene Berechnung zu starten, müssen wir sie von main oder von runBlocking aus aufrufen, was auf die gleichen Garantien hinausläuft wie in Haskell und Scala.

Leider neigt Code, der mit Monaden geschrieben wurde, dazu, unter dem Gewicht all der flatMaps und der damit verbundenen Verschachtelung zusammenzubrechen.

doOneThing().flatMap { x ->
  doAnotherThing(x).flatMap { y ->
    // imagine if your code was even longer!
    return x to y
  }
}

Aus diesem Grund bieten sowohl Scala als auch Haskell eine spezielle Syntax für die Arbeit mit Monaden - for comprehensions für erstere und do notation für letztere. Derselbe Code kann viel klarer geschrieben werden als

for {
  x <- doOneThing()
  y <- doAnotherThing(x)
} yield (x, y)

Dies ist ein Schritt in die richtige Richtung, aber nicht perfekt. Insbesondere erzwingt die monadische Notation eine starke stilistische Trennung zwischen monadischem und nicht-monadischem Code. Wenn Sie auch nur irgendeinen Effekt in eine Funktion einführen müssen, sind Sie gezwungen, sie von der "reinen" Teilmenge der Sprache in die "monadische" umzuschreiben. Dieses Problem erstreckt sich nicht nur auf Codeblöcke, sondern auch auf die für sie verfügbaren Operatoren. Das berühmte traverse Betrieb

def traverse[F[_], A, B](x: List[A], f: A => F[B]): F[List[B]]

ist nichts anderes als Ihre reguläre Liste map, bei der f jedoch einige Effekte einbauen kann. Noch einmal: Die Lösung ist gut. Aber in einer perfekten Welt würden Sie von den Entwicklern nicht verlangen, zwei Sätze von Abstraktionen zu lernen, wenn einer ausreichen würde.

Die Signatur von traverse zeigt einen weiteren wesentlichen Teil des monadischen Stils der Effekte, nämlich Typen höherer Art. Da Monaden Typen sind, die andere Typen als Parameter annehmen - wie Option oder Result - um Hilfsfunktionen bereitzustellen, die mit verschiedenen Monaden arbeiten, muss man eine Möglichkeit haben, auf diese Typen zu verweisen. In Scala wird dies durch die Anzahl der Unterstriche nach dem Typnamen explizit gemacht, wie z.B. oben; in Haskell wird dies nicht explizit in der Signatur gemacht, aber dennoch vom Compiler überprüft. Selbst Jahrzehnte nach ihrer Einführung werden Typen höherer Art immer noch als fortschrittliche Funktion angesehen und haben sich in den gängigen Programmiersprachen noch nicht durchgesetzt.

Kotlin benötigt keine höherwertigen Typen, um eine ähnliche Abstraktion zu bieten, da dieselben Funktionen, die über "reguläre" Funktionen wie map funktionieren, auch bei der Verwendung in der Coroutine-Welt funktionieren. Die Designer der Sprache Kotlin haben meiner Meinung nach einen großartigen Punkt im Designraum gefunden, an dem sie die gleichen Fähigkeiten anbieten können, aber ohne die Kosten einer solch komplexen Funktion.

Zusammensetzung der Effekte

Monaden haben einige inhärente Probleme mit der Komposition, was in der Praxis bedeutet, dass Sie nicht einfach eine neue Monade aus den Effekten von zwei Monaden erstellen können. Es gibt mehrere Techniken, um dieses Problem zu überwinden, aber die wichtigste sind Monadentransformatoren. Anstatt selbst eine Monade zu sein, "umhüllt" ein Transformator eine andere Monade und fügt ihr eine zusätzliche Fähigkeit hinzu. Die Komposition von "wir behalten eine zustandsabhängige DbConnection" mit "wir benötigen eine Config im Kontext" mit "wir können E/A-Aktionen durchführen" wird beispielsweise wie folgt dargestellt:

StateT[DbConnection, ReaderT[Config, IO], User]

Beachten Sie, dass ein Transformator wie StateT einen höherwertigen Typ hat als eine reguläre State Monade. Letztere nimmt einen "normalen" Typ als Parameter, während erstere eine Monade (einen Typ mit einem Loch) als Parameter nimmt. Dies ist einer der schwierigsten Berge, die es zu erklimmen gilt, um zu verstehen, wie Effekte in Haskell und Scala funktionieren.

Wir schlagen vor, in Kotlin von diesem Stil abzurücken und einfach die für eine Funktion erforderliche context zu erweitern. Kontext-Empfänger komponieren ohne weitere Anweisungen des Entwicklers. In diesem Sinne funktionieren sie ähnlich wie algebraische Effekte, wie sie in effectful oder polysemy. Konkret bedeutet dies, dass Sie im Gegensatz zu den Monadentransformatoren keine feste Reihenfolge für die Injektion von Kontexten haben.

Fehler überall!

Eine interessante Anwendung dieses Stils ist eine ergonomische Schnittstelle für Fehler. In Arrow 2.0 ist [Raise] der Name des Umfangs/Effekts des Kurzschlusses mit einem Fehler des Typs E. Eine Funktion mit der Signatur

context(Raise<E>) () -> A

kann in ein Either, ein Result (ist E ist Throwable) und viele andere ähnliche Typen [ausgeführt] werden. Nichts hält Sie jedoch davon ab, mehr als eine Raise in Ihrem Kontext zu haben, wenn Sie Fehler ganz explizit angeben möchten.

context(Raise<DbConnectionError>, Raise<MalformedQuery>)
suspend fun queryUsers(q: UserQuery): List<User>

Kontext-Empfänger arbeiten gut mit Varianz zusammen, so dass die Injektion eines Empfängers für Raise gleichzeitig für beide Elemente im Kontext funktionieren kann. Vergleichen Sie dies mit EitherT[DbConnectionError, EitherT[MalformedQuery, IO], List[User]], wo Sie jedes der EitherT unabhängig und in der durch den monadischen Stapel vorgegebenen Reihenfolge behandeln müssen.

Beschränkungen

Fortsetzungen sind wirklich mächtig. Tatsächlich können Sie Cont verwenden, um jede andere Monade zu emulieren. Dies sagt jedoch nichts über die Ergonomie der Verwendung einer solchen Emulation aus, so dass dieses Argument für diese Diskussion nicht wirklich gültig ist. Außerdem gibt es in Kotlin einige Einschränkungen bei der Verwendung von Coroutinen, was die Ausdruckskraft des vorgeschlagenen Stils einschränkt.

Kotlins Fortsetzungen sind One-Shot, d.h. sie können nur einmal aufgerufen werden. Das reicht aus, um viele interessante Effekte zu modellieren. Für solche wie ResourceScope oder CoroutineScope möchten Sie die Fortsetzung normal aufrufen. Für solche wie errors dürfen Sie sie nicht aufrufen, aber auch das ist erlaubt. Diese Einschränkung tritt allerdings ein, wenn Sie versuchen, die Listenmonade (auch bekannt als Nicht-Determinismus) in Kotlin zu implementieren. Um ein konkretes Beispiel zu nennen: In Arrow Analysis benötigten wir eine solche Abstraktion, und die Lösung erfordert eine explizite flatMap, wenn Sie denselben Code mehr als einmal ausführen können.

Wir sollten One-Shot Continuations nicht zu schnell als Fehler abtun. Wenn Sie sicher sind, dass Ihr Code höchstens einmal aufgerufen wird, können Sie alle Arten von veränderlichen Zuständen verwenden, um deren Funktionsweise zu optimieren. Der Preis dafür, dass Sie mehrere Aufrufe zulassen, besteht darin, dass Sie bei jedem Aufruf von suspend eine ganze Closure erstellen müssen, was nicht immer vernachlässigbar ist. Meiner Meinung nach haben die Kotlin-Designer wieder einen großartigen Punkt im Designraum gefunden, denn sie wissen, dass jeder Leistungseinbruch von der JVM-Gemeinschaft, auf die sie abzielen, schlecht aufgenommen werden würde.

Das Fehlen von Typen höherer Art in Kotlin bedeutet, dass einige Musternicht abstrahiert werden können. Wir haben bereits besprochen, dass einige von ihnen nicht mehr benötigt werden - wie - aber andere sind immer noch relevant. Insbesondere ermöglichen höherkettige Typen die Abstraktion von der typischen Struktur einer Monade in der Operational oder Free Monaden. Das ist in Kotlin nicht möglich, so dass wir auf die Codegenerierung zurückgreifen müssen, wie es mit dem Inikio KSP-Plugin geschieht.

Fazit

In diesem Beitrag beschreiben wir einen Programmierstil in Kotlin, der auf Fortsetzungen und Kontextempfängern basiert. Dieser Stil ist für Kotliners idiomatisch und verbessert den "monadischen Stil", der in anderen funktionalen Sprachen verfügbar ist. Sprechen Sie mir nach: keine flatMap mehr!

Xebia Funktional ð Kotlin

Wir, die Abteilung für funktionale Programmierung bei Xebia, sind große Fans von Kotlin und erforschen die vielen Möglichkeiten, die es für die Backend-Szene bietet. Wir sind stolze Betreuer von Arrow, einer Reihe von Begleitbibliotheken zu Kotlins Standardbibliothek, Coroutines und Compiler, und bieten Kotlin-Schulungen an, um Kotlin-Experten zu werden. Wenn Sie mit uns sprechen möchten, können Sie unser Kontaktformular verwenden oder uns auf dem Kotlin Slack folgen.

Verfasst von

Alejandro Serrano Mena

Contact

Let’s discuss how we can support your journey.