Blog

Ausnahmen in Kotlin: Jenseits von Try/Catch

Raphael De Lio

Raphael De Lio

Aktualisiert Oktober 15, 2025
11 Minuten

Die meisten von uns sind von Java auf Kotlin umgestiegen. Wenn wir mit der Entwicklung von Kotlin beginnen, ist unser natürlicher Ansatz, die Dinge so zu tun, wie wir sie in Java getan haben.

Wir fangen an zu experimentieren: Zuerst versuchen wir, nullbare Typen zu vermeiden, dann spielen wir mit Datenklassen und lernen Erweiterungsfunktionen kennen. Irgendwann sind wir begierig darauf, neue Ansätze zu erforschen, um alles andere zu implementieren.

Dieser Artikel ist ein Teil meiner Reise zum Erlernen von Kotlin. Ich möchte herausfinden, was sich hinter dem try/catch-Block verbirgt und mich mit verschiedenen Techniken für die Fehlerbehandlung in unseren Kotlin-Anwendungen beschäftigen.

Fangen wir an! :)

Bildbeschreibung

Der Java-Weg

Lassen Sie uns gemeinsam eine einfache Anwendung implementieren.

Wir beginnen mit der Implementierung einer Funktion, die für das Lesen des Inhalts einer Datei verantwortlich ist:

fun readContentFromFile(filename: String): String {
    return try {
        File(filename).readText()
    } catch (e: IOException) {
        println("Error reading the file: ${e.message}")
        throw e
    }
}

Unsere Funktion versucht, den Text aus einer Datei zu lesen, und wenn etwas schief geht, geben wir die Fehlermeldung aus und lösen eine Ausnahme aus.

Dann lassen Sie uns eine weitere Funktion implementieren, die den Inhalt dieser Datei transformiert und zwei Zahlen zurückgibt:

fun transformContent(content: String): CalculationInput {
    val numbers = content.split(",").mapNotNull { it.toIntOrNull() }

    if (numbers.size != 2)
        throw Exception("Invalid input format")

    return CalculationInput(numbers[0], numbers[1])
}

Die Funktion transformContent beginnt damit, den Text mit dem Komma als Trennzeichen aufzuteilen, und wandelt dann unsere Chunks in Ints um. Das Ergebnis ist eine Liste von Ints.

Dann prüfen wir, ob wir nur zwei Zahlen in dieser Liste haben, und wenn das nicht der Fall ist, lösen wir eine Ausnahme aus, die besagt, dass wir ein ungültiges Eingabeformat haben.

Andernfalls geben wir ein CalculationInput-Objekt zurück, das unsere beiden Zahlen für weitere Berechnungen enthält.

So sieht unsere CalculationInput-Klasse aus:

data class CalculationInput(val a: Int, val b: Int)

Mit diesem Objekt in der Hand können wir unsere Divide-Funktion aufrufen, die die erste Zahl durch die zweite teilt und den Quotienten dieser Berechnung ausspuckt:

fun divide(a: Int, b: Int): Int {
    if (b == 0)
        throw ArithmeticException("Division by zero is not allowed")

    return a / b
}

In dieser Funktion prüfen wir zunächst, ob der Divisor gleich Null ist und lösen eine Exception aus, wenn dies der Fall ist. Andernfalls geben wir einfach den Quotienten unserer Division zurück.

Cool, kombinieren wir alles in unserer Hauptfunktion und umgeben alles mit einem try/catch-Block.

fun main() {
    try {
        val content = readContentFromFile("input.txt")
        val numbers = transformContent(content)
        val quotient = divide(numbers.a, numbers.b)
        println("The quotient of the division is $quotient")
    } catch (e: IOException) {
        println("Error reading the file: ${e.message}")
    } catch (e: Exception) {
        println("Error: ${e.message}")
    }
}

Und genau wie Sie habe auch ich es mit Argusaugen betrachtet. Ist das wirklich der Weg von Kotlin?

Nun, lassen Sie uns nach Alternativen suchen.

Entdecken Sie den Kotlin-Weg

runCatching

Der erste Ansatz, den wir uns ansehen werden, ist der runCatching Kontext.

Lassen Sie uns unsere Hauptfunktion umstrukturieren, um zu sehen, wie sie aussehen wird:

    runCatching {
        val content = readContentFromFile("input.txt")
        val numbers = transformContent(content)
        val quotient =  Calculator().divide(numbers.a, numbers.b)
        println("The quotient of the division is $quotient")
    }.onFailure {
        println("Error: ${it.message}")
    }

runCatching ermöglicht es uns, kompakteren und lesbaren Code zu schreiben, indem wir die Ausnahmebehandlung in einem einzigen Funktionsaufruf kapseln. Verbesserung der Prägnanz unseres Codes.

Außerdem fördert es einen funktionalen Programmierstil, der es einfacher macht, Operationen zu verketten und Ergebnisse auf eine idiomatischere Kotlin-Art zu verarbeiten.

Darüber hinaus gibt der Kontext runCatching einen expliziten Ergebnistyp zurück, der entweder den Erfolg oder den Misserfolg einer Operation darstellt, wodurch klar wird, wie Fehler im aufrufenden Code behandelt werden sollten.

Um diesen expliziten Ergebnistyp zu präsentieren, können wir unseren Code so umgestalten, dass er wie folgt aussieht:

fun main() {
    val result = runCatching {
        val content = readContentFromFile("input.txt")
        val numbers = transformContent(content)
        val quotient = Calculator().divide(numbers.a, numbers.b)
        println("The quotient of the division is $quotient")
    }

    result.onFailure { 
        println("Error: ${it.message}")
    }
}

Der Typ Result ist jedoch noch viel mächtiger. Und genau das werden wir als nächstes sehen.

Ergebnis

Um den Typ Result zu präsentieren, werden wir unsere Anwendung noch weiter umstrukturieren.

Beginnen wir damit, dass wir den Rückgabetyp unserer Funktionen ändern und stattdessen einen Ergebnistyp zurückgeben.

Zum Beispiel wird unsere Funktion readContentFromFile nun ein Result vom Typ String zurückgeben:

fun readContentFromFile(filename: String): Result<String> {
    return try {
        Result.success(
            File(filename).readText()
        )
    } catch (e: IOException) {
        Result.failure(e)
    }
}

Jetzt gibt unsere Funktion unseren Inhalt zurück, der in ein Result Objekt oder unsere Ausnahme, die in ein Result.Failure Objekt verpackt ist.

Lassen Sie uns das Gleiche mit unseren anderen Funktionen tun:

fun transformContent(content: String): Result<CalculationInput> {
    val numbers = content.split(",").mapNotNull { it.toIntOrNull() }

    if (numbers.size != 2) {
        return Result.failure(Exception("Invalid input format"))
    }

    return Result.success(CalculationInput(numbers[0], numbers[1]))
}

Stellen Sie außerdem sicher, dass Sie innerhalb der Funktionen keine Exceptions mehr auslösen, sondern die Exception innerhalb von Result.failure() zurückgeben.

fun divide(a: Int, b: Int): Result<Int> {
    if (b == 0)
        return Result.failure(Exception("Division by zero"))

    return Result.success(a / b)
}

So weit, so gut.

Und jetzt wird es lustig. Result ist ein flexibler Typ und kann auf verschiedene Arten gehandhabt werden. Lassen Sie uns unsere Funktion main umgestalten und diese verschiedenen Möglichkeiten erkunden.

Falten: Die erste ist fold, eine Funktion, bei der wir sowohl Erfolgs- als auch Misserfolgsfälle behandeln müssen.

fun main() {
    val content = readContentFromFile("input.txt")
    content.fold(
        onSuccess = {
            // Do something with the content of the file
        },
        onFailure = {
            println("Error reading the file: ${it.message}")
        }
    )
}

In unserem Fall müssten wir bei jedem Ergebnis erneut fold aufrufen, was zu einer verschachtelten Struktur führen würde:

fun main() {
    readContentFromFile("input.txt").fold(
        onSuccess = {content ->
            transformContent(content).fold(
                onSuccess = {numbers ->
                    divide(numbers.a, numbers.b).fold(
                        onSuccess = {quotient ->
                            println("The quotient of the division is $quotient")
                        },
                        onFailure = {
                            println("Error: ${it.message}")
                        }
                    )
                },
                onFailure = {
                    println("Error: ${it.message}")
                }
            )
        },
        onFailure = {
            println("Error reading the file: ${it.message}")
        }
    )
}

Ja... Das sieht nicht gut aus. Lassen Sie uns stattdessen versuchen, sie zu kartieren:

Karte:

fun main() {
    readContentFromFile("input.txt").map { content ->
        transformContent(content).map { numbers ->
            divide(numbers.a, numbers.b)
        }
    }    }.fold(
        onSuccess = { content ->
            content.fold(
                onSuccess = { numbers ->
                    numbers.fold(
                        onSuccess = { quotient ->
                            println("The quotient of the division is $quotient")
                        },
                        onFailure = {
                            println("Error: ${it.message}")
                        }
                    )
                },
                onFailure = {
                    println("Error: ${it.message}")
                }
            )
        },
        onFailure = {
            println("Error: ${it.message}")
        }
    )
}

Das sieht schon etwas besser aus, nicht wahr? Leider gibt es keine flatMap Funktion, so dass wir hier mit Result<Result> enden. Das bedeutet, dass wir unseren Code wieder mehrfach verschachteln müssen:

fun main() {
    readContentFromFile("input.txt").map { content ->
        transformContent(content).map { numbers ->
            divide(numbers.a, numbers.b)
        }
    }    }.fold(
        onSuccess = { content ->
            content.fold(
                onSuccess = { numbers ->
                    numbers.fold(
                        onSuccess = { quotient ->
                            println("The quotient of the division is $quotient")
                        },
                        onFailure = {
                            println("Error: ${it.message}")
                        }
                    )
                },
                onFailure = {
                    println("Error: ${it.message}")
                }
            )
        },
        onFailure = {
            println("Error: ${it.message}")
        }
    )
}

Durch die Verwendung der Ergebnisklasse wird die Fehlerbehandlung expliziter, einfacher zu lesen und zu pflegen und weniger anfällig für versteckte Fehler im Vergleich zu herkömmlichen try-catch-Blöcken. Es kann jedoch als etwas umstritten angesehen werden, denn da es von uns verlangt, sowohl Erfolgs- als auch Fehlerpfade zu behandeln, kann man sagen, dass es eine Wiedereinführung von geprüften Ausnahmen in Kotlin ist.

Was bleibt also für uns übrig?

Der Pfeilweg

Arrow ist eine funktionale Programmierbibliothek für Kotlin, die eine Reihe leistungsstarker Abstraktionen für die Arbeit mit funktionalen Datentypen bietet.

Bei einigen dieser Konstrukte handelt es sich um Funktionen, die die Fähigkeiten der Klasse Result erweitern und es dem Entwickler ermöglichen, zu entscheiden, ob er Fehlerpfade explizit behandeln möchte. Dadurch wird die Fehlerbehandlung in bestimmten Situationen unkomplizierter und weniger kompliziert.

Die beiden Konstrukte, mit denen wir uns heute beschäftigen werden, sind die Funktion flatMap und der Kontext result.

Fügen wir Arrow zu unseren Abhängigkeiten hinzu:

dependencies {
    implementation("io.arrow-kt:arrow-core:1.2.0-RC")
}

Und lassen Sie uns unsere main Funktion noch einmal umgestalten:

flatMap:

Wie wir bereits besprochen haben, der Typ Result bietet von Haus aus die Funktion map. Wenn wir jedoch mehrere Ergebnisobjekte zuordnen, erhalten wir Ergebnisse von Ergebnissen (Result<Result>).

Arrow erweitert die Möglichkeiten des Typs Result durch die Funktion flatMap, die es uns ermöglicht, am Ende nur ein einziges Ergebnis zu erhalten:

fun main() {
    val result = readContentFromFile("input.txt").flatMap { content ->
        transformContent(content).flatMap { numbers ->
            divide(numbers.a, numbers.b)
        }
    }

    result.fold(
        onSuccess = {quotient ->
            println("The quotient of the division is $quotient")
        },
        onFailure = {
            println("Error: ${it.message}")
        }
    )
}

Ergebnis Kontext:

Die Funktion result ist ein Wrapper, der seinen Codeblock innerhalb eines Result -Kontextes ausführt und dabei alle Ausnahmen abfängt und in ein Failure -Objekt einpackt.

Die Methode bind() wird verwendet, um den Result zu entpacken. Wenn Result ein Success ist, wird der Wert entpackt; wenn es ein Failure ist, wird die Ausführung angehalten und der Fehler weitergegeben.

fun main() {
    result {
        val content = readContentFromFile("input.txt").bind()
        val numbers = transformContent(content).bind()
        val quotient = divide(numbers.a, numbers.b).bind()
        println("The quotient of the division is $quotient")
    }.onFailure { 
        println("Error: ${it.message}")
    }
}

Diese Ansätze machen unseren Code im Vergleich zu den traditionellen try/catch-Blöcken viel sauberer und einfacher zu verstehen. Aber...

Die Sache hat einen Haken

Sollten wir in unserem Geschäftscode überhaupt Ausnahmen abfangen? Wie wir bereits gesehen haben, bietet Kotlin die Klasse Result und die Funktion runCatching für eine idiomatischere Fehlerbehandlung. Es ist jedoch wichtig zu überlegen, wann und wo Sie diese Mechanismen einsetzen.

Zum Beispiel fängt runCatching alle Arten von Throwable ab, einschließlich JVM-Fehlern wie NoClassDefFoundError, ThreadDeath, OutOfMemoryError oder StackOverflowError. In der Regel sollten Anwendungen nicht versuchen, sich von diesen schwerwiegenden Problemen zu erholen, da es oft wenig gibt, was man dagegen tun kann. Catch-All-Mechanismen wie runCatching sind für geschäftlichen Code nicht zu empfehlen, da sie die Fehlerbehandlung unklar und verworren machen können.

Außerdem ist es wichtig, zwischen erwarteten Fehlern und unerwarteten Logikfehlern im Geschäftscode zu unterscheiden. Während erwartete Fehler behandelt und behoben werden können, weisen unerwartete Logikfehler oft auf Programmierfehler hin, die eine Korrektur der Logik des Codes erfordern. Werden beide Arten von Fehlern auf die gleiche Weise behandelt, kann dies zu Verwirrung führen und die Pflege des Codes erschweren.

getOrThrow()

Die Klasse Result bietet auch eine Funktion getOrThrow(). Diese Funktion gibt den erwarteten Wert zurück oder löst eine Ausnahme aus. Schauen wir uns an, wie sie funktioniert:

fun main() {
    val content = readContentFromFile("input.txt").getOrThrow()
    val numbers = transformContent(content).getOrThrow()
    val quotient = divide(numbers.a, numbers.b).getOrThrow()
    println("The quotient of the division is $quotient")
}

Für die meisten unserer geschäftlichen Codes ist dies der Ansatz, den wir verfolgen sollten. Eine Ausnahme bedeutet, dass es ein Problem mit unserem Code gibt. Wenn es ein Problem mit unserem Code gibt, sollten wir unseren Code korrigieren.

Aber dann fragen Sie sich vielleicht: Warum überhaupt ein Ergebnis?

Der Kotlin-Weg

Indem wir den Typ Result nicht zurückgeben und einfach Ausnahmen in unserem Code auftauchen lassen, erreichen wir letztendlich das gleiche Ergebnis:

fun main() {
    val content = readContentFromFile("input.txt")
    val numbers = transformContent(content)
    val quotient = Calculator().divide(input.a, input.b)
    println("The quotient of the division is $quotient")
}

Wenn die Datei nicht existiert oder die Eingabe nicht korrekt ist, kommt es trotzdem zu Ausnahmen.

Die Wahrheit ist, dass wir uns für den Großteil unseres Geschäftscodes keine Gedanken über das Abfangen von Ausnahmen machen sollten.

"Als Faustregel gilt, dass Sie in allgemeinem Kotlin-Code keine Ausnahmen abfangen sollten. Das riecht nach Code. Ausnahmen sollten von einem Top-Level-Framework-Code Ihrer Anwendung behandelt werden, um die Entwickler auf die Fehler im Code aufmerksam zu machen und Ihre Anwendung oder den betroffenen Betrieb neu zu starten. Das ist der Hauptzweck von Ausnahmen in Kotlin." - Roman Elizarov (Projektleiter für die Programmiersprache Kotlin)

Wie wir im vorigen Abschnitt besprochen haben, ist es oft nicht sinnvoll, Ausnahmen im Geschäftscode abzufangen, denn sie treten auf, wenn der Entwickler einen Fehler macht und die Logik des Codes unterbrochen wird.

Anstatt Ausnahmen abzufangen, die nicht wiederherstellbar sind, sollten wir stattdessen die Logik unseres Codes korrigieren.

Fazit

In Kotlin können die traditionellen try-catch-Blöcke Ihren Code schwerer lesbar und wartbar machen. Stattdessen ermutigt die Sprache dazu, idiomatischere Fehlerbehandlungstechniken zu verwenden, wie z.B. die Klasse Result und die Funktion runCatching, um die Lesbarkeit und Wartbarkeit des Codes zu verbessern.

Es ist jedoch entscheidend, zwischen erwarteten Fehlern und unerwarteten Logikfehlern in Ihrem Code zu unterscheiden und zu entscheiden, wann und wo Sie Mechanismen zur Fehlerbehandlung einsetzen. Bibliotheken wie Arrow können zusätzliche Tools bereitstellen, die die Fehlerbehandlung noch einfacher und weniger kompliziert machen.

Wenn Sie diese Best Practices befolgen und die entsprechenden Tools verwenden, können Sie lesbaren, wartbaren und effektiven Kotlin-Code schreiben.

Und in den meisten Fällen ist es am besten, wenn Sie Ihren Code einfach halten und nicht übermäßig verkomplizieren

Beispiele auf GitHub:

GitHub - Beispiele für Kotlin-Fehlerbehandlung

Quelle

Sehen Sie es auf YouTube

[embed]https://www.youtube.com/watch?v=ThlFnnaxsuE[/embed]

Verfasst von

Raphael De Lio

I talk about technology

Contact

Let’s discuss how we can support your journey.