Kotlin-Updates: Kotlin 1.5.20

Mit diesem Artikel starte ich eine Serie von Update-Artikel zu meinem Kotlin-Buch. In diesem ersten Beitrag geht es um die Neuerungen in Kotlin von Version 1.4 bis zur aktuellen Version 1.5.20.

Dieser Text bezieht sich auf die folgenden Versionsnummern:

IntelliJ: 2021.1
Kotlin: 1.5.20
JDK: 16

Weitere Kotlin-Update-Artikel auf meiner Website finden Sie hier:

https://kofler.info/tag/kotlin-updates

Neues Release-Modell

Im Oktober 2020 hat Jetbrains ein neues Release-Modell angekündigt, demzufolge in Zukunft halbjährlich neue Versionen erscheinen sollen. Version 1.5 hat im Mai 2021 den Anfang gemacht, Ende 2021 soll 1.6 folgen, im Frühjahr 2022 Version 1.7 usw. Dazwischen gibt es kleiner Releases, 1.5.10, 1.5.20 usw., außerdem bei Bedarf Bugfix-Releases (1.5.21 etc.)

Der rasche Release-Zyklus hat zur Folge, dass es nicht bei jedem Release revolutionäre Neuerungen gibt. Tatsächlich sind die praktischen Auswirkungen der Neuerungen zwischen Version 1.4 und der aktuellen Version 1.5.20 gering. Die interessantesten Neuerungen gibt es aus meiner Sicht in der Standardbibliothek.

Standardbibliothek

Unsigned int types, also UByte, UShort, UInt und ULong gelten jetzt als stabil.

Die Methoden upper- und lowercase ersetzen toUpperCase und toLowerCase:

println("abCD".uppercase())   // ABCD

toChar, toInt: Aus nicht ganz nachvollziehbaren Gründen erscheinen den Kotlin-Entwicklern die Methoden toChar, toInt etc. zur Umwandlung von Zeichen in den zuhörigen ASCII/UTF-8-Code bzw. die Rückumwandlung aus einer Zahl in das entsprechende Zeichen verwirrend. Stattdessen erzeugen Sie neue Char-Elemente nun mit Char(n) und erhalten den Code eines Zeichens mit c.code. Also:

// bisher
val n = 65
val c = n.toChar()        // c = 'A'
val n2 = c.toByte()       // n2 = 65

// neu
val n = 65
val c = Char(n)           // c = 'A'
val n2 = c.code.toByte()  // n2 = 65

c.toInt() und n.toChar() wurden im letzten Moment doch nicht als deprecated erklärt, wohl aber c.toByte() etc.

Ganzzahlige Division und Rest: Ergänzend zu den Operatoren / und % und der entsprechenden Funktionen rem gibt es zwei neue Funktionen floordiv und mod. Bei positiven Zahlen führen beide Wege zum gleichen Ergebnis:

val n1 = 17
val n2 = 4
println(n1 / n2)           // 4
println(n1 % n2)           // 1
println(n1.floorDiv(n2))   // 4
println(n1.mod(n2))        // 1

Aber bei negativen Zahlen sucht / das Ergebnis, das näher bei 0 liegt, während floorDiv immer das nächst kleiner Resultat liefert, also gewissermaßen nach unten rundet. Das hat dementsprechende Auswirkungen auf den verbleibenden Rest.

val n1 = -17
val n2 = 4
println(n1 / n2)           // -4
println(n1 % n2)           // -1
println(n1.floorDiv(n2))   // -5
println(n1.mod(n2))        //  3

Collections: Es gibt einige Collections-Methoden, die zum einen neu sind oder die ich zum anderen ganz einfach übersehen habe, als ich Kapitel 11 »Lambda-Ausdrücke und funktionale Programmierung« verfasst habe:

  • lst.distinct() eliminiert alle Doppelgänger in einer Liste.

  • lst.distinctBy { lambda } eliminiert ebenfalls Doppelgänger, aber verwendet den Lambda-Ausdruck, um die »Gleichheit« zu testen.

  • coll.firstNotNullOf { lambda } liefert das erste Elemente, das nach Anwendung des Lambda-Ausdruck nicht null ist. Der Lambda-Ausdruck kann im einfachsten Fall einfach it sein. Die Methode löst einen Fehler aus, wenn es kein passendes Element findet.

  • coll.firstNotNullOfOrNull { lambda } funktioniert wie oben, gibt aber null zurück, wenn kein passendes Element gefunden hat. (Dementsprechend ist der Ergebnisdatentyp optional, also z.B. Int?.)

Path-Ausdrücke können nun besonders elegant mit dem neuen /-Operator zusammengesetzt werden (der aber vorher importiert werden muss):

import java.nio.file.Paths
import kotlin.io.path.div
...
val home = Paths.get(System.getProperty("user.home"))
val downloads = home / "Downloads"
println(downloads)  // "/home/kofler/Downloads"

Um ein Verzeichnis nach Dateien eines Typs zu durchsuchen, können Sie die ausgesprochen praktische Methode listDirectoryEntries verwenden:

// pdf hat den Typ Path
for (pdf in downloads.listDirectoryEntries("*.pdf"))
    println(pdf)

listDirectoryEntries arbeitet nicht rekursiv, die Groß- und Kleinschreibung muss exakt stimmen. Mehrere Suchmuster können Sie so kombinieren: listDirectoryEntries("*.{jpg,jpeg,JPG,JPEG}")

Duration-API: Die »Duration und Time Measurement API« (kotlin.time, siehe Abschnitt 7.3 im Buch) gilt weiterhin als experimentell. Allerdings haben sich sowohl die interne Darstellung der Daten (jetzt Long, bisher Double) als auch diverse Eigenschaften/Methoden geändert. Beispielsweise wird aus dem wunderbar lesbaren 100.milliseconds jetzt das viel umständlichere Duration.milliseconds(100). Schade!

// Rechnen mit Zeitspannen
val myDur = Duration.minutes(2) + Duration.seconds(3) - Duration.milliseconds(10) * 0.66
println(myDur)                       // 123s (gerundet)
println(myDur.inWholeMilliseconds)   // 122970 (exakt)

Java-Kompatibilität: Records

Nicht nur Kotlin entwickelt sich weiter, sondern auch Java. Damit Kotlin kompatibel zu Java bleibt und es weiterhin möglich ist, Kotlin- und Java-Code in einem Projekt zu kombinieren, muss Kotlin die jeweils neuesten Java-Features unterstützen. Dementsprechend ist Kotlin ab Version 1.5 mit Java-Records (das sind unveränderliche, also immutable Klassen) sowie mit geschlossenen Klassen und Schnittstellen (sealed classes and interfaces) kompatibel.

Java-Records haben eine Ähnlichkeiten mit den in Kotlin schon viel früher eingeführten Datenklassen. Um eine eigene data class kompatibel zu Java-Records zu machen, markieren Sie diese mit @JvmRecord.

@JvmRecord
data class Person(val name: String, val age: Int)

Die Annotation ist nur erlaubt, wenn sämtliche Eigenschaften unveränderlich sind (Deklaration mit val, nicht mit var). Diese Annotation setzt außerdem voraus, dass Ihr Kotlin-Projekt zumindest JDK 15 als Fundament verwendet. Bei meinen Tests mit JDK 16 gelang die Einstellung eines JVM-16-Targets nur in einem Gradle-Projekt, nicht aber in einem simplen IntelliJ-Projekt. (Dort ist die höchste erlaubte Ziel-JVM merkwürdigerweise 13. Vielleicht liegt hier noch ein IntelliJ-Problem vor?)

# in build.gradle
compileKotlin {
    kotlinOptions.jvmTarget = '16'
}

Java-Kompatibilität: Sealed Classes

Versiegelte Klassen/Schnittstellen verhindern, dass diese außerhalb des Projekts erweitert werden können. Auf den ersten Blick erscheint das im Vergleich zu den schon lange verfügbaren finale Klassen nichts Neues zu sein. Das stimmt aber nicht ganz: Finale Klassen können nie erweitert werden, versiegelte Klassen hingegen schon — allerdings nur im aktuellen Projekt (genaugenommen sogar nur im selben Paket, sofern es davon mehrere gibt), nicht außerhalb.

Als Entwickler gibt Ihnen eine versiegelte Klasse die Möglichkeit, jede Erweiterung/Vererbung außerhalb zu unterbinden, diese Grundfunktion objektorientierter Programmierung innerhalb Ihres Projekts aber sehr wohl zu nutzen.

// die Bicycle-Klasse kann nur innerhalb des Projekts erweitert werden,
// nicht außerhalb
sealed class Bicycle(val gears: Int, val weight: Double)

class MountainBike(gears: Int,
                   weight: Double,
                   val suspension: Double) 
         : Bicycle(gears, weight) 

Eine versiegelte Schnittstelle (also ein sealed interface) funktioniert analog wie eine versiegelte Klasse. Versiegelte Klassen/Schnittstellen von Kotlin 1.5 sind zum entsprechenden Feature von JDK 15 kompatibel.

Inline-Klassen

Inline-Klassen sind ganz »primitive« Klassen, die lediglich einen Datentyp unter einem anderen Namen zugänglich machen (also »Wrapper-Klassen«). Aktuelle JVMs können Daten derartigen Klassen besonders effizient verarbeiten — und Kotlin kann dies, dann der Annotation @JvmInline jetzt auch. Der durch die Wrapper-Klasse eingeführte Overhead wird also beim Kompilieren eliminiert.

@JvmInline
value class Password(private val s: String)

Im Prinzip ist Password bei dieser Deklaration einfach eine andere Bezeichnung für eine Zeichenkette. Im Unterschied zu einem typealias besteht darin, dass ein typealias einen zusätzlichen Namen für einen vorhandenen Datentyp definiert. Egal, ob der originale oder der Alias-Name verwendet wird — die Daten sind austauschbar. Eine Inline-Klasse ist hingegen ein eigener Datentyp.

val mypassword = Password("geheim")
val s1: String = mypassword  // Fehler, type mismatch

typealias PW = String
val mypw: PW = "secret"
val s2: String = mypw        // OK

Quellen/Links

Details

Download

Das aktualisierte Beispielprojekt zu Abschnitt 7.3 »Duration and Time Measurement API« können Sie hier herunterladen:

https://kofler.info/uploads/kotlin/kap07-kotlin-time.zip

Die Kotlin-Updates-Serie

Weitere Kotlin-Update-Artikel finden Sie hier auf meiner Website:

https://kofler.info/tag/kotlin-updates