Kotlin statt Java: Effizienter entwickeln

Seite 2: Sicher ist sicher

Inhaltsverzeichnis

Trotz Eleganz und Prägnanz: Konstruktive Sicherheit steht bei Kotlin ganz oben. Das beginnt damit, dass die Sprache auf die Bequemlichkeit der User setzt und Unveränderbarkeit aufwandsfrei beziehungsweise als Standard an erste Stelle setzt: Variablen mit val statt var sind unveränderbar. Auch die Interfaces der Standard-Collections wie List, Set oder Map sind readonly. Für veränderbare Listen stehen MutableList, MutableSet oder MutableMap im Repertoire.

Ähnlich ist die Situation bei Klassen und Methoden, die ohne die explizite Angabe des Schlüsselwortes open standardmäßig final und damit nicht offen für Vererbung oder das Überladen ihrer Methoden sind. Das Überschreiben einer Klassenmethode oder das Implementieren einer Interfacemethode muss stets explizit mit override angezeigt werden. Das ist auf der einen Seite eine mutige Entscheidung im Sprachentwurf, denn sie erfordert Achtsamkeit beim Entwurf der Bibliotheken, um bewusst an den geeigneten Stellen Erweiterbarkeit zu ermöglichen. Auf der anderen Seite hilft die Reserviertheit ab Werk, die Komplexität gering zu halten. Zudem gibt sie dem Compiler mehr Gelegenheiten für Performance-Optimierungen, da Konstrukte wie der Polymorphie-Overhead in der JVM entfallen. Nicht zuletzt hilft Unveränderbarkeit in Multithreading-Kontexten gegen Bugs und unnötige Kopfschmerzen.

open class ErweiterbareKlasse {
    open fun ueberschreibbareMethode(): String {
        return "Ich bin dein Vater."
    }
}

class AbgeleiteteKlasse : ErweiterbareKlasse() {
    override fun ueberschreibbareMethode() =
        "... ich dein Sohn!"
}

Den größten Gewinn an Führung und Sicherheit, den alteingesessene Java-Entwicklerinnern und -Entwickler zu schätzen wissen dürften, ist der effektive und proaktive Umgang mit der Nullability von Werten. Kotlin hat sich das Ziel gesetzt, Fehler aufgrund von Nullwerten beim Kompilieren zu melden, statt zu NullPointerException-Fehlern zur Laufzeit werden zu lassen. Dazu unterscheidet das Typsystem zwischen nullable und non-nullable types. Kotlin kennt beispielsweise zwei Typen für String-Werte: Der Typ String muss immer einen Wert tragen, während String? potenziell null sein kann. Ungeprüfte Dereferenzierungen, die häufig zur Mutter aller Java-Exceptions führen, erkennt der Compiler und fordert zum Beseitigen auf.

Gegen unnötigen Frust helfen Smart Cast: Kurzoperatoren und Utility-Methoden beim Umgang mit Nullwerten. Erkennt der Compiler beispielsweise durch ein if-Statement, dass ein Wert gesichert vorhanden ist, überführt er den Typ T? automatisch in T. Mit !! lässt er sich jederzeit ohne vorherige Prüfung zum Konvertieren zwingen – mit allen Konsequenzen wie Laufzeitfehlern.

var nonNullable: String = "Oh"
var nullable: String? =
    if (Random.nextBoolean()) "yes."
    else null

// Die foglenden Zeilen wuerden Compile-Fehler erzeugen. 
//    nonNullable = null
//    nonNullable = nullable
//    nullable.trim()

// Kotlin Smartcasts helfen
if (nullable != null) {
    nonNullable = nullable.trim()
}

// Safe nagivation operator
nullable = nullable?.trim()

// Elvis operator
nonNullable = nullable ?: "no."

// Ich-weiss-es-besser
//  NPE-Gefahr voraus!
nonNullable = nullable!!
nullable!!.trim()

Interessant ist dabei der Umgang von Kotlin mit Rückgabewerten aus Java-Code, bei denen zunächst unklar ist, ob sie null-Werte enthalten oder nicht. Der Compiler berücksichtigt passend konfigurierte Nullability-Annotationen und arbeitet entsprechend mit T oder T?. Da das meist nicht der Fall ist, greift ein pragmatischer Kompromiss in Kotlin: Derartige Werte führt der Compiler zunächst als internen Plattformtyp T!, der gleichzeitig sowohl T als auch T? entspricht. Der Clou: Werte vom Typ T! lassen sich ohne erzwungene Prüfungen direkt als non-nullable Typ T verwenden. Das ermöglicht ein reibungsloses Arbeiten mit Java-Bibliotheken, ohne unnötig zu frustrieren. Der Preis dafür sind potenzielle Schlupflöcher für NullPointerExceptions an ungenügend behandelten Java-Bibliotheksaufrufen.

Kotlin greift nicht nur bei der Sicherheit, sondern auch bei Funktionen hilfreich unter die Arme. Über Extension Methods und Extension Properties lassen sich fremde Klassen um zusätzliche Funktionen und feldfreie Properties erweitern, ohne die Originalklasse zu verändern. Davon macht insbesondere die Kotlin-Standardbibliothek ausgiebig Gebrauch und rüstet allein an den Java Collections unzählige Helferlein wie distinct(), filter(), forEach()oder shuffle() nach. Kotlin 1.7 stellt allein bei den Collections 165 zusätzliche Funktionen bereit.

Entsprechend gestaltete Extensions können mit fehlenden Werten umgehen. In folgendem Codeausschnitt findet die Prüfung auf null in der Extension-Funktion prepend statt, weshalb der Compiler einen Aufruf mit null erlaubt, der nicht zu einer Exception führt.

// Neue Extension-Funktion fuer Strings & null
fun String?.prepend(c: String): String {
    return if (this != null) c + this else c;
}

// Extension Property. Hier: ohne null
val String.exclaimed : String
    get() = "$this!"


var s: String? = null
s = s.prepend("Hi ") // fehlerfrei: "Hi "
println(s.exclaimed) // Dank smart cast: "Hi !"

Konventionen bringen noch mehr Eleganz. Kotlin erlaubt es, Operatoren für eigene Typen zu definieren. Per Konvention wird für ein a + b nach einer als Operator markierten und von den Typen passenden Funktion für den Aufruf a.plus(b) gesucht. Der folgende Code zeigt das Vorgehen und illustriert darüber hinaus eine weitere Konvention: Funktionen in der Form component1(), component2() und so weiter lassen sich für ein komfortables Destructuring nutzen. Da Kotlin sie für Data Classes automatisch erzeugt, lässt sich das Ergebnis der Operation über val (w, l, h) = b wieder in seine Einzelteile zerlegen.

data class Point(val x: Int, val y: Int, val z: Int) {
    operator fun plus(o: Int): Point {
        return Point(x + o, y + o, z + o)
    }
}

fun main() {
    val a = Point(7, 3, 1)
    val b = a + 2

    // Destructuring via component1()..3()
    val (w, l, h) = b
    println("$b: Raumvolumen = ${w*h*l}")
    // Point(x=9, y=5, z=3): Raumvolumen = 135
}

Ein letztes Beispiel für Kotlins Ausdrucksstärke: when ist das Gegenstück zu switch in Java und anderen C-ähnlichen Sprachen. Der wesentliche Unterschied ist, dass das Statement nicht nur exakte Übereinstimmung mit Werten prüfen kann, sondern darüber hinaus Ranges, Ausdrücke und Instanz-Prüfungen kennt. Da sich when als Ausdruck verwenden lässt, reicht der Block in folgendem Code das Ergebnis direkt weiter:

private fun describe(x: Number):String {
    val specialNums = setOf(13, 42, 99)
    return when (x) {
        1, 0 -> "binaer"

        !is Int -> "kein Integer"

        in -9..9 -> "klein"
        in specialNums -> "besonders"
        !in -99..99 -> "ausserhalb des Rahmens"

        else -> "nichts von alledem"
    }
}

fun main() {
    listOf(0, 3.14, 42, 98).forEach {
        println("$it ist ${describe(it)}")
    }
    // 0 ist binaer
    // 3.14 ist kein Integer
    // 42 ist besonders
    // 98 ist nichts von alledem
}

Das Programmierparadigma der funktionalen Programmierung hat in den letzten Jahren an Gewicht gewonnen. Im Gegensatz zu Java, das funktionale Elemente wie Lambda-Ausdrücke erst relativ spät gelernt hat, haben die Gestalter von Kotlin die funktionale Programmierung von Beginn an berücksichtigt. Daher sind Funktionen Sprachelemente erster Klasse (First-class Functions): Sie lassen sich Variablen und Datenstrukturen zuweisen, anderen Funktionen als Parameter übergeben sowie als deren Rückgabewerte entgegennehmen. Funktionen, die andere Funktionen als Parameter erhalten, bezeichnet man als Higher-order Functions, also Funktionen höherer Ordnung.

val numbers = listOf(1,2,3,4,5,6)
val aggregator: (Int, Int) -> Int = 
    { acc: Int, number: Int -> acc + number }
val sum = numbers.fold(0, aggregator)

// Lambda-Parameter als Block; implizites `it`
val evenNums = numbers.dropWhile { 
    it % 2 == 0
} 

Der Code weist der Variable aggregator eine anonyme Funktion in Form eines Lambda-Ausdrucks zu. Die Funktion kombiniert zwei Int- Werte zu einem Ergebnis und dient anschließend in der Extension Function fold als Parameter für eine Funktion höherer Ordnung. Diese typischen funktionalen Muster sind mit Kotlin deutlich weniger sperrig als mit Java und der Streams API. Für Lambda-Ausdrücke gibt es weitere Vereinfachungen, um den Umgang bequemer zu gestalten.

Praktisch ist zudem ein syntaktischer Zucker für Lambda-Ausdrücke, die als letzte Parameter einer Funktion dienen. Sie können direkt als Block nach dem Funktionsaufruf stehen. Erwartet die Lambda-Funktion nur einen Funktionsparameter, kann dessen Deklaration ebenfalls entfallen und direkt die implizit deklarierte Variable it referenziert werden. Den passenden Typ erschließt sich der Compiler dank Typinferenz.

// Dank syntaktischem Zucker fuer trailing lambdas
// sind die folgenden Statements alle gleichwertig:

view.setOnClickListener({ e -> doSomething(e) })

view.setOnClickListener() { e -> doSomething(e) }

view.setOnClickListener { e -> doSomething(e) }

view.setOnClickListener { doSomething(it) }

Koroutinen sind erst seit Kotlin 1.3 Bestandteil der Sprache. Sie helfen bei der nebenläufigen Programmierung, die durch die stark parallelisierte Verarbeitung vernetzter Anwendungen und moderner Multicore-Prozessoren deutlich an Relevanz gewonnen hat. In der Vergangenheit mussten sich die Entwickler bei der nebenläufigen Programmierung um viele technische Details wie die Synchronisation kümmern, was zeitaufwendig und fehleranfällig war. Koroutinen vereinfachen die nebenläufige Programmierung. Das Prinzip ähnelt den Goroutines aus Go.

Koroutinen sind pausierbare Berechnungen, die gleichzeitig mit anderem Code laufen. Sie sind grundlegend mit Threads vergleichbar, aber wesentlich schlanker und nicht an einen Thread gebunden. Nach dem Pausieren können sie auf einem anderen Thread weiterlaufen. Die Kernsprache kennt nur die grundlegenden Mittel. Die Bibliothek kotlinx.coroutines stellt die einzelnen High-Level-Funktionen wie launch und runBlocking zur Kommunikation und Synchronisation bereit und erledigt das Thread-Handling.

import kotlinx.coroutines.*

fun main() {
  runBlocking {      // Eroeffnet blockierend einen CoroutineScope
    launch {         // Startet im Hintergrund eine Coroutine
    // Dieser Lambda-Block wird in der Coroutine ausgefuehrt
    delay(999L)      // Suspendiert die Coroutine
    println("Welt!") // Ausgabe nach Fortsetzung
    }
    // Starte parallel 20x suspendierbare Funktionen
    repeat(20) { launch { printDot() } }
    print("Hallo") 
  }
println("Ende")
}

// Suspendable function: Nur aus CoroutineScope aufrufbar 
suspend fun printDot() {
  delay(500L)
  print(".")
}

Der Code zeigt ein einfaches Beispiel: Zunächst eröffnet runBlocking() einen neuen CoroutineScope. Koroutinen laufen immer im Kontext eines solchen Scope, der als Brücke zwischen der normalen Ablaufumgebung im Hauptthread und der Laufzeitumgebung agiert, die den Lebenszyklus der Koroutinen steuert. Der runBlocking-Aufruf blockiert, bis alle darin gestarteten Koroutinen beendet sind.

Anschließend startet der Code über launch() eine erste Koroutine als Lambda-Ausdruck im Hintergrund. Sie gibt den Text "World!" verzögert aus. Der delay()-Aufruf ist von besonderer Bedeutung: Diese spezielle Suspending Function stoppt die enthaltene Koroutine für eine bestimmte Zeit, während der Thread andere Koroutinen ausführen kann. printDot() definiert eine Suspending Function über das suspend-Schlüsselwort. Sie lässt sich aus Koroutinen wie eine normale Funktion aufrufen und kann ihrerseits andere Suspending Functions wie delay() aufrufen. In regulären Funktionen unterbindet der Compiler den Einsatz dagegen.

Häufig ist das Ergebnis einer nebenläufigen Berechnung für den weiteren Programmablauf wichtig. Für die Kommunikation zwischen Koroutinen dienen  Channels. Sie sind vergleichbar mit Messages Queues, über die Nachrichten beziehungsweise Daten ausgetauscht werden. Die Zustellung der Nachrichten erfolgt nach dem FIFO-Prinzip (First in, First out).

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*

fun main() = runBlocking {
    val channel = Channel<Int>()  // Channel definieren
    launch {
        val result = someExpensiveComputation()
        channel.send(result) // Ergebnisse kommunizieren
        channel.close()      // Letztes Ergebnis signalisieren
    }
    // Via for-Schleife lesen der Channel-Werte bis zum close()
    for (y in channel) println(y)
}

Ein Produzent kann beliebig viele Nachrichten erzeugen. Das Schließen eines Channels mit close() zeigt an, dass keine weiteren Nachrichten folgen. Prinzipiell können mehrere Koroutinen gleichzeitig Nachrichten an einen Channel senden (Fan-in). Ebenso können mehrere gleichzeitig Nachrichten empfangen. Jede Nachricht wird an genau einen Empfänger zugestellt.

Die Kotlin-Koroutinen bieten weit mehr Funktionen, und für Interessierte empfiehlt sich ein Blick auf das Kotlin-Framework für vernetzte Anwendungen Ktor, das mit CIO (Coroutine-based I/O) eine komplett asynchrone Engine mitbringt, die auf die leichtgewichtigen Koroutinen setzt.

Die Fehlerbehandlung in Koroutinen ist leider etwas umständlich. Fehler lassen sich nur innerhalb einer Koroutine mit einem Try-Catch-Block behandeln, nicht in anderen Koroutinen. Die Exception wird stattdessen in der Hierarchie nach oben propagiert und sorgt für die Terminierung der Koroutinen im CoroutineScope. Zum Behandeln von Exceptions dient der CoroutineExceptionHandler, der entweder im CoroutineScope oder in der Top-Level-Koroutine existieren muss:

import kotlinx.coroutines.* 

fun main() {
  val handler = CoroutineExceptionHandler { _, exception ->
    println("CoroutineExceptionHandler got $exception")
  }
    
  val topLevelScope = CoroutineScope(Job())

  topLevelScope.launch(handler) {
    launch {
      throw 
        RuntimeException("RuntimeException in nested coroutine")
    }
  }

  Thread.sleep(100)
}