Wer ein neues Projekt auf der Java Virtual Machine (JVM) aufsetzt, steht vor einer grundlegenden Entscheidung: Java oder Kotlin? Doch warum überhaupt wählen? Warum nicht einfach situativ die eine oder die andere einsetzen?

Kotlin kann jede beliebige Java-Bibliothek verwenden, man kann ein Projekt sogar in beiden Sprachen gemeinsam schreiben (z. B. schreibt ein Team, mit dem ich zusammenarbeite, seine JUnit-Tests in Kotlin und den Hauptcode in Java) und im Laufe der Zeit zu Kotlin oder zurück zu Java migrieren. Kotlin wurde mit Blick auf die Interoperabilität mit Java entwickelt, da JetBrains seine IDEs (IntelliJ IDEA, WebStorm usw.) schrittweise auf diese Sprache umstellen wollte. Aber wie funktioniert das? Was macht Kotlin, damit eine Interoperabilität in diesem Ausmaß möglich wird?

In Teil 1 dieser Serie wurde das Ökosystem von Java und Kotlin verglichen, und Teil 2 beschäftigte sich mit den Multiplattform-Fähigkeiten beider Sprachen. In diesem Teil gibt es einen tiefen Einblick darin, wie Interoperabilität funktioniert und wie Kotlin mehr Immutability als Java bieten kann, während es gleichzeitig interoperabel bleibt. In Teil 4 geht es schließlich darum, wie die Sprachen sich schlagen bei der Modellierung von Geschäftslogik.

Bevor die Interoperabilität zwischen Kotlin und Java im Detail beleuchtet wird, braucht man allerdings ein solides Verständnis von Kotlin selbst. Der erste Abschnitt liefert daher eine kompakte Einführung in die Sprache.

Als Beispieldomäne für diesen Abschnitt dient Martin Fowlers Videothek. Das ist ein Geschäft, in dem man etwas namens „DVD“ ausleihen konnte – eine glänzende Scheibe, auf der Videos aller Art gespeichert waren. Diese Scheiben mussten rechtzeitig zurückgegeben werden, damit keine „Verleihgebühren“ anfielen. Die Gebühr für die Ausleihe variierte je nach Art des ausgeliehenen Videos. Es war die Zeit von „Blockbuster“, vor langer Zeit, bevor man alle Inhalte streamen konnte.

Wir sind gar nicht so verschieden, du und ich

Kotlin ist nicht die einzige Java-Alternative, die zu Bytecode kompiliert wird und auf der Java Virtual Machine (JVM) läuft. Es gibt auch Ceylon, Clojure, Groovy und Scala, um nur einige zu nennen. Das Besondere an Kotlin ist, dass es Java-Entwicklern vertraut vorkommt, wie eine natürliche Weiterentwicklung von Java, ohne sie mit zu vielen fremden Konzepten zu überfordern. Das sieht man am besten, wenn man bei der main-Methode startet, dem Einstiegspunkt einer Anwendung.

Main

Seit JDK 25 (September 2025) gibt es kompakte Source-Dateien, die Java-main-Methoden recht kompakt machen, wie unten zu sehen ist.

CODE
// Java JavaMovieSandbox.java
void main() {  
    IO.println("Java Movie Sandbox");  
}

Das ist gültige Java-Syntax, allerdings nur für die main-Methode. Dieses Feature wurde hinzugefügt, um „den Einstieg für Java-Neulinge zu erleichtern“. Die Idee dahinter ist, die Anzahl der Konzepte zu reduzieren, die man gleichzeitig lernen muss. public class Main, static und String[] args sind weiterhin vorhanden, können aber schrittweise eingeführt werden, wenn sie benötigt werden. In Java ist nach wie vor alles eine Klasse. Die Sprache unterstützt keine Top-Level-Funktionen.

Die main-Methode in Kotlin sieht sehr ähnlich aus.

CODE
// Kotlin KotlinMovieSandbox.kt
package de.richargh.ktkonja.feature  
  
fun main() {  
    println("Kotlin Movie Sandbox")  
}

Kotlin verfügt über Top-Level-Funktionen. Im Gegensatz zu Java, wo nur die main-Methode eine Sonderbehandlung erhält, kann man Top-Level-Funktionen in Kotlin an beliebiger Stelle schreiben.

Wie funktioniert das auf der JVM? Auf der JVM werden Kotlin-Top-Level-Funktionen zu Klassen kompiliert, da die JVM nur Klassen unterstützt. Der Bytecode sieht entsprechend so aus:

CODE
public final class de/richargh/ktkonja/feature/KotlinMovieSandboxKt {

  public final static main()V
}

Die Datei KotlinMovieSandbox.kt wurde zu einer Klasse KotlinMovieSandboxKt, und die Funktion fun main wurde innerhalb dieser Klasse zu public final static main.

Funktionen

In Java können Funktionen innerhalb einer Klasse mit State oder in einer Hilfsklasse als static definiert werden. Letzteres sieht wie folgt aus:

CODE
// videostore/JUsers.java
public final class JUsers {  
    private JUsers(){}  
    public static boolean isDummyUser(String name){  
        //        ^^^^                ^^^^^^^^^^
        //        return type         parameters
        return name.startsWith("Dummy-");
    }  
}
// -----------------
// in some other file
import static videostore.JUsers.isDummyUser;

void main(){
    var isDummy = isDummyUser("Dummy-the-great");
}

Da Kotlin Top-Level-Funktionen unterstützt, kann man dort den Codeausschnitt wie folgt verkürzen:

CODE
// videostore/KUsers.kt
fun isDummyUser(name: String): Boolean {  
    //          ^^^^^^^^^^     ^^^^
    //          parameters     return type
    return name.startsWith("Dummy-")
}
// -----------------
// in some other file
import videostore.isDummyUser

fun main(){
    val isDummy = isDummyUser("Dummy-the-great")
}

In Kotlin wird der Typ immer hinter dem Feld, dem Wert oder der Methode angegeben (bekannt für jeden, der TypeScript programmiert). Ansonsten sind die Deklaration und der Aufruf in Kotlin und Java sehr ähnlich.

Die Beispiele sind bereits sehr übersichtlich, aber geht das nicht noch kompakter? Die isDummyUser-Methoden machen schließlich nichts anderes, als eine einzige Zeile aufzurufen und das Ergebnis zurückzugeben. Die geschweiften Klammern { } sorgen nur für unnötige vertikale Verlängerung. Derzeit kann Java hier keine Verbesserungen vornehmen. Es gibt jedoch einen Entwurf namens concise method bodies, der sich an der Deklaration von Lambdas orientiert und die Möglichkeit bieten würde, die Methode wie folgt zu verkürzen:

CODE
// videostore/JUsers.java
public final class JUsers {  
    private JUsers(){}  
    public static boolean isDummyUser(String name) ->  name.startsWith("Dummy-");
}

In Kotlin ist so ein Feature bereits vorhanden. Dort heißt es „expression bodies“, wodurch aus isDummyUser Folgendes wird:

CODE
// with explicit return type
fun isDummyUser(name: String): Boolean = name.startsWith("Dummy-")
// with return type inferred from startsWith
fun isDummyUser(name: String) = name.startsWith("Dummy-")

Wie funktioniert das auf der JVM? Wie zuvor wird die Funktion auf oberster Ebene bei der Übersetzung in Bytecode einfach zu einer Methode public final static isDummyUser(Ljava/lang/String;)Z mit einem regulären Funktionskörper.

Wie man sieht, bietet Kotlin ein paar Komfortfunktionen, bleibt aber interoperabel mit Java. Weitere Aspekte davon zeigen sich beim Thema Klassen.

Klassen

Java hat Klassen. Sie sehen so aus:

CODE
public class JRentalService {  
    private final Map<JRentalId, JRental> rentals; // <1>
  
    public JRentalService(
        Map<JRentalId, JRental> rentals // <2>
    ) {  
        this.rentals = rentals; // <3>
    }  
}

Was bei Java-Klassen etwas mühsam ist, ist der Umgang mit mehreren Feldern. Zuerst muss man sie deklarieren <1>, dann ihre Werte als Parameter übergeben <2> und schließlich zuweisen <3>. Kotlin profitiert hier von 20 Jahren Erfahrung. Diese Muster waren bei der Entwicklung der Sprache bereits bekannt, und Kotlin konnte das Muster „Deklarieren-Übergeben-Zuweisen“ sehr komfortabel gestalten. In Kotlin sieht die entsprechende Klasse wie folgt aus:

CODE
class KRentalService(private val rentals: Map<KRentalId, KRental>)

Man hätte auch public val rentals schreiben können, und das Feld wäre außerhalb der Klasse sichtbar (public), aber nicht neu zuweisbar (val) gewesen. Zu beachten ist auch, dass die class nicht als public gekennzeichnet ist. Das Schlüsselwort existiert zwar, aber in Kotlin ist public der Standard-Sichtbarkeitsmodifizierer und kann weggelassen werden, was zu kompakterem Code führt.

Wie funktioniert das auf der JVM? Der Compiler fügt das Feld final rentals zur Klasse hinzu, ebenso den Konstruktorparameter und die Zuweisung des Feldes im Konstruktor.

Als Nächstes geht es darum, Typen zu erstellen, um Daten möglichst kompakt zu speichern.

Daten

Seit JDK 16 (2021) werden Daten in Java häufig in Records gespeichert. Will man das Konzept einer Vermietung modellieren, kann man es wie folgt schreiben:

CODE
record JRental(JRentalId id, JDay daysRented){}

In Kotlin würde man dies mit einer data class modellieren:

CODE
data class KRental(val id: KRentalId, val daysRented: KDay)

Will man sicherstellen, dass die an den Konstruktor übergebenen Daten gültig sind, würde man in Java den kompakten kanonischen Konstruktor verwenden:

CODE
record JDay(int rawValue){  
    JDay {  
        if(rawValue < 0)  
            throw new IllegalArgumentException("Day must be >= 0");  
    }  
}

In Kotlin entspricht dies dem init {}-Block:

CODE
data class KDay(val rawValue: Int){  
    init {  
        require(rawValue >= 0)  
    }  
}

Wie funktioniert das auf der JVM? Auf der JVM sind data classes einfach Klassen mit einem Konstruktor, Konstruktorparametern, Feldern (rawValue), Zugriffsmethoden (getRawValue()) sowie generierten equals-, hashCode- und toString-Methoden. Der init {}-Code ist Teil des regulären Konstruktors und folgt direkt auf die Feldzuweisung. Dies unterscheidet sich nicht allzu sehr von records, die im Bytecode zu class X extends java/lang/Record werden.

Es ist erwähnenswert, dass records und data classes sehr ähnlich, aber nicht ganz identisch sind. Bei beiden geht es um die Daten. Beide bieten automatisch generierte equals-, hashCode- und toString-Methoden. Sie werden verglichen anhand ihrer Werte, und gleich, wenn ihre Werte übereinstimmen:

CODE
// Java
void main() {  
    IO.println(new JDay(1).equals(new JDay(1))); // true
}

Das Gleiche gilt für Kotlin:

CODE
// Kotlin
fun main() {  
    println(KDay(1).equals(KDay(1))) // true
}

Records sind „transparente Träger für unveränderliche Daten“. Transparent, weil alle beinhalteten Daten öffentlich sein müssen. Alles, was im Konstruktor deklariert wird, ist außerhalb der Klasse sichtbar. Unveränderlich, nun ja, weil ihre Daten nicht verändert werden können (*es sei denn, die Daten, die an den Record übergeben werden, sind veränderlich).

Diese Eigenschaften gelten für die Kotlin-Datenklassen, die bisher gezeigt wurden, aber man kann diese Einschränkungen auch lockern.

  • val entspricht final in Java: die Variable ist nicht neu zuweisbar.
  • in Kotlin gibt es auch die Möglichkeit, var statt val zu verwenden. var ist, wie in Java, eine neu zuweisbare Variable.
  • in Kotlin können Felder auch als private deklariert werden.

So kann man auch veränderbare data classes schreiben, die (einen Teil) ihrer Daten verbergen:

CODE
data class KRental(
    val id: KRentalId, // public, immutable
    private var daysRented: KDay // private, mutable
)

Ob eine solche Klasse benötigt wird, hängt ein wenig vom jeweiligen Anwendungsbereich ab. Im Domain-Driven Design sind Entitäten zum Beispiel oft veränderbar. Wenn man die Daten dieser zusätzlich noch als private deklariert, kann man erzwingen, dass die Geschäftslogik innerhalb der Klasse geschrieben werden muss. Da andere Dateien die Daten nicht einsehen können, kann Geschäftslogik für diese Daten sich nicht über mehrere Dateien verteilen. Durch das Einschränken der Sichtbarkeit kann man so erzwingen, dass Änderungen der Geschäftslogik nur Änderungen in einer Klasse erfordern, wodurch der Code stärker entkoppelt ist (Information Hiding Principle). Das geht leider nicht immer so perfekt. Manchmal hat man Situationen in denen Daten für klassenübergreifende Geschäftslogik zur Verfügung gestellt werden müssen. Für solche Situationen kann man gezielt Properties bereitstellen.

Properties

Wenn man in Kotlin etwas als val id oder var daysRented deklariert, wird es zu einem Property dieser (data) class. Dies unterscheidet sich von einem Feld in Java, da Properties es ermöglichen, die Implementierung des Properties zu ändern, ohne die API dieser (data) class zu ändern.

Angenommen der Code soll nun jeden Tag erfassen, an dem das Mietobjekt gemietet wurde (z. B. 2026-01-02, 2026-01-05, 2026-01-06), aber daysRented soll weiterhin die Anzahl der aufeinanderfolgenden Tage zurückgeben. In Kotlin kann man den Konstruktor so ändern, dass er eine Liste der gemieteten Tage entgegennimmt, und ein Property hinzufügen, welches die gemieteten Tage aus dieser Liste aggregiert:

CODE
data class KRental(
    val id: KRentalId, 
    private val allDaysRented: List<LocalDate> // changed, now private List
){  
    val daysRented: KDay // a property
        get() = aggregateDaysRented(allDaysRented) // what to do on get
      
    private fun aggregateDaysRented(daysRented: List<LocalDate>): KDay {  
        // ...  
    }  
}

Mit diesem neuen Design ändert sich der Konstruktor, aber alle Stellen, an denen daysRented aufgerufen wird, sehen weiterhin dieselbe API und müssen nicht geändert werden.

In Java kann man auf Kotlin-Properties wie folgt zugreifen:

CODE
Fee calculateFee(KRental rental){
    KDay daysRented = rental.getDaysRented(); // "getting" the property
    // ... 
}

In Kotlin würde das so aussehen:

CODE
fun calculateFee(rental: KRental): Fee {
    val daysRented: KDay = rental.daysRented // "getting" the property
    // ...
}

Wie funktioniert das auf der JVM? Properties sind automatisch generierte Get-/Set-Methoden, die private Felder (daysRented) setzen, zurückgeben oder berechnete Werte zurückgeben können. Wenn das Property öffentlich und val ist, generiert der Compiler eine „Get“-Methode (getDaysRented()). Ist es öffentlich und var, wird zusätzlich eine „set“-Methode (setDaysRented(daysRented)) generiert.

Will man eine veränderbare renterId hinzufügen, könnte man eine private internalRenterId hinzufügen und zum Setzen ein öffentliches renterId-Property deklarieren, das beim Setzen die Berechtigungen des Ausleihenden überprüft:

CODE
data class KRental(
    val id: KRentalId, 
    val daysRented: KDay,
    private var internalRenterId: KRenterId
){  
  
    var renterId: KRenterId  
        get() = internalRenterId // "getter" returns the internal
        set(value) {  
            if(value.rawValue.startsWith("tmp"))  
                throw IllegalArgumentException("Temporary users cannot rent media")  
            // we only set the value, if all checks have passed
            internalRenterId = value  
        }
}

Dieses Property kann in Java wie folgt gesetzt werden:

CODE
void changeRenter(KRental rental, KRenterId renterId){
    rental.setRenterId(renterId); // setting the property
    // ...
}

In Kotlin würde das so aussehen:

CODE
fun changeRenter(rental: KRental, renterId: KRenterId) {
    rental.renterId = renterId // "setting" the property
    // ...
}

Syntaktisch sehen Properties in Kotlin beim Zugriff aus Kotlin-Code wie Felder aus und in Java entsprechen sie den Methoden getXyz und setXyz. Sie bieten mehr Flexibilität als Felder, da man die Implementierung ändern, beim Zugriff auf das Property zusätzliche Maßnahmen ergreifen und Validierungen hinzufügen kann, ohne die API der (data) class zu verändern. Während öffentliche Felder also ausschließlich für Konstanten verwendet werden sollten (siehe Oracle-Tutorial oder Effective Java, 3. Auflage Punkt 16: Verwenden Sie in öffentlichen Klassen Zugriffsmethoden, keine öffentlichen Felder), können Properties durchaus public sein.

In Java kommen automatisch generierte record-Zugriffsmethoden den Properties am nächsten. Records unterliegen jedoch mehr Einschränkungen als data classes. Man kann zwar die Zugriffsmethoden eines records überschreiben, aber weder den zurückgegebenen Typ noch die Art der Datenspeicherung ändern, ohne gleichzeitig die Signatur des Konstruktors ändern zu müssen. Der Typ der internen Daten und die externen Accessoren sind immer synchron. Am ehesten lässt sich das daysRented-Get-Beispiel nachbilden, indem man eine weitere Methode hinzufügt, die den alten Typ zurückgibt, und den alten Parameter + Accessor in allDaysRented umbenennt:

CODE
record JRental(
    JRentalId id, 
    List<LocalDate> allDaysRented // change the data type in the constructor
){  
    KDay daysRented(){ // add method to return data in the old type
        return aggregateDaysRented(allDaysRented);  
    }  
  
    private KDay aggregateDaysRented(List<LocalDate> daysRented){  
        // ...  
    }  
}

Bei Records ist eine Neuzuweisung von Feldern nicht möglich. Stattdessen kann man bei einem Record lediglich eine neue Instanz mit dem geänderten Feld zurückgeben. Für das Beispiel mit dem renterId-Set sieht das wie folgt aus:

CODE
record JRental(JRentalId id, JDay daysRented, JRenterId renterId){
    JRental withRenterId(JRenterId renterId){
        return new JRental(id, daysRented, renterId); 
        //                                 ^^^^^^^^
        // return a new instance with one field changed
    }
}

Natürlich kann man Daten auch als reguläre Java-Klassen modellieren und die Zugriffsmethoden selbst schreiben, doch dann entgehen einem die Vorteile der automatisch generierten Methoden equals, hashCode und toString sowie des Record-Matching.

Damit ist die Einführung in beide Sprachen abgeschlossen. Stilistisch unterscheiden sich Java und Kotlin nicht allzu sehr, fast so als ob Letzteres von Ersterem inspiriert worden wäre. Es gibt natürlich schon einige syntaktische Besonderheiten, die für Verwirrung sorgen könnten, daher geht der nächste Abschnitt kurz auf die wichtigsten Besonderheiten der Syntax ein.

Ein kleiner Exkurs zur Syntax

Im vorigen Kapitel wurden einige syntaktische Besonderheiten von Kotlin nicht weiter erklärt, um die Einführung zügiger voranzubringen. In diesem Exkurs sollen jetzt die offensichtlichsten davon näher betrachtet werden:

  • var und val: Felder in class-Definitionen oder Inline-Variablen können als var (= Variable) oder val (= Wert) deklariert werden. val wird gegenüber var bevorzugt, da unveränderliche Daten leichter zu verstehen sind. Wenn man möchte, kann man den Typ auch inline deklarieren und die Typinferenz überschreiben, z. B. val myList: List<String> = arrayListOf(...).
  • new: Kotlin kennt das Schlüsselwort new nicht. Instanzen werden wie gewohnt durch Aufruf des Konstruktors erstellt, aber ihm ist kein new vorangestellt. Dies ist analog dazu, wie man in Python Objekte initialisiert.
  • fun: In Kotlin müssen Methoden über das Schlüsselwort fun(ction) deklariert werden. TypeScript verwendet function, Rust verwendet fn und Swift verwendet func. Alle sind Sprachen mit Typ-Postfix.
  • Semikolons: In Kotlin sind Semikolons optional. Man kann ; am Ende einer Zeile schreiben, muss es aber nicht. Daher ist es idiomatisch sie wegzulassen.
  • public ist der Standard-Sichtbarkeitsmodifizierer und kann weggelassen werden.
  • Unit: Kotlin kennt das Schlüsselwort void nicht. Stattdessen gibt es den Typ Unit. Beide werden im Bytecode zum Typ void kompiliert.
  • Implizite Syntax: Wenn eine Funktion Unit zurückgibt, kann man den Typ weglassen. Wenn eine (data) class keinen Körper hat, kann man { } weglassen. Wenn ein Lambda nur einen Parameter hat, muss man diesen nicht explizit deklarieren, sondern kann über die implizite Variable it darauf zugreifen.
  • Int statt int: In Kotlin werden primitive Typen mit einem Großbuchstaben deklariert. Im Bytecode wird dies jedoch meist zu i(nt) kompiliert. Der boxed Integer-Typ wird automatisch nur dort verwendet, wo es notwendig ist: für nullable Ints oder generische Typen wie List<Integer>.
  • fun statt static: Da Kotlin über Top-Level-Funktionen verfügt, nutzt die Sprache diese intensiv. In Java initialisiert man eine Liste über die statische Methode List.of(1, 2, 3), in Kotlin stattdessen per Funktion listOf(1, 2, 3).
  • override ist ein Schlüsselwort: In Java kennzeichnet man überschriebene Methoden mit einer optionalen @Override-Annotation. In Kotlin ist es ein obligatorisches Schlüsselwort.

Ist einer dieser Punkte ein Ausschlusskriterium? Ich hoffe nicht. Man muss sich lediglich damit vertraut machen, und meiner Erfahrung nach haben Java-Entwickler den Dreh ziemlich schnell raus.

Sind diese Punkte ein Grund, sich für Kotlin zu entscheiden? Nicht für sich genommen. Sie verleihen der Sprache erst einmal nur ihr charakteristisches Erscheinungsbild. Trotz syntaktischer Unterschiede bleibt Kotlin interoperabel mit Java, wie der nächste Abschnitt zeigen wird.

Interoperabilität

Kotlin bietet eine hervorragende (ich wage zu sagen: nahezu perfekte) Interoperabilität mit der Java-Plattform. In der Regel legt man Kotlin-Code im Verzeichnis src/main/kotlin ab und kann dann jeden Code aus src/main/java aufrufen. Das geht auch in die andere Richtung. Java-Code kann Kotlin-Code aufrufen. Man kann frei von dem einen Code in den anderen springen. Ein mir bekanntes Team hat beispielsweise seine Kernmodule in Kotlin neu geschrieben und die unterstützenden beziehungsweise generischen Module in Java belassen. Ein Grund dafür, dass dies so gut funktioniert (abgesehen von all der Arbeit, die der Compiler im Hintergrund leistet), ist, dass Kotlin auf der Java-Standardbibliothek aufbaut.

Standardbibliothek

Ein List<> in Kotlin ist ein List<> in Java, ein Kotlin-Map<> ist ein Java-Map<>. Kotlin-Methoden können den Typ zurückgeben oder an Java-Methoden übergeben, ohne dass eine Konvertierung erforderlich ist.

Extension Functions

Kotlin kann der Standardbibliothek sogar Funktionen hinzufügen, und zwar über ein Feature namens „Extension Functions“ (Erweiterungsfunktionen). Diese ermöglichen es, jedem Typ neue Funktionen hinzuzufügen, auch wenn diese im Original-Code nicht vorhanden waren (C#-Entwicklern ist dieses Konzept vertraut). Listen in Kotlin verfügen beispielsweise über verschiedene Komfortmethoden wie .map oder flatMap, die es in Java nicht gibt. Man kann sie sogar selbst definieren, um beispielsweise ein internes Domänenobjekt auf ein externes DTO (Data Transfer Object) zu mappen.

CODE
fun KRental.toDto(): KRentalDto { // <1>
    return KRentalDto(  
        this.id.rawValue,  
        this.daysRented.rawValue  
    )  
}

fun KRentalDto.toDomain(): KRental { 
    return KRental(  
        KRentalId(this._id),  
        KDay(this.daysRented)  
    )  
}

fun getRental(/** **/): KRentalDto {
    val aRental: KRental = /** code here **/
    // ... even more code here
    val dto = aRental.toDto() // <2>
    return dto
}

KRental befindet sich in der Domänenschicht und KRentalDto in der API-Schicht; sie sollten keine gegenseitigen Abhängigkeiten aufweisen. Da die Mapping-Funktionen beide Typen kennen müssen, müssen sie in einer höheren Schicht angesiedelt sein, die sowohl die Domänen- als auch die API-Schicht einsehen kann. Mit einer Extension Function <1> kann man in der Mapping-Schicht definieren, dass toDto eine neue Funktion auf dem Typ KRental ist, und sie dann <2> so verwenden, als wäre sie von Anfang an Teil des Typs gewesen.

In der IDE fühlt sich das so an, als wäre toDto regulärer Teil von KRental. Beim Tippen vom . nach aRental werden alle Funktionen angezeigt, die für den Typ verfügbar sind, inklusive aller Extension Functions.

Wie funktioniert das auf der JVM? Extension Functions sind, wie Top-Level-Funktionen, im Bytecode einfach reguläre static-Funktionen. Das Objekt, das sie erweitern, wird als erstes Argument übergeben.

Extension Functions lassen sich auch auf primitiven Typen deklarieren. Zum Beispiel kann man die Funktion .days() zu jedem Int hinzufügen. Bei Aufruf wandelt diese ein Int in den Typ Duration um (val limit: Duration = 10.days()). Damit kann man kompakten und ausdrucksstarken Code schreiben, denn 10.days() macht eindeutig klar, was die Einheit der Zahl ist. Hinzu kommt, dass das Typsystem erzwingt, dass Duration nur mit anderen Durations verglichen werden kann, was wiederum die Typsicherheit erhöht und Fehler einschränkt:

CODE
fun isOverdue(duration: Duration): Boolean = duration > 10.days()

Im vorangegangenen Beispiel werden Objekte mithilfe des Operators > verglichen. Fast so, als ob sie primitive Zahlen wären. Dies ist möglich, da Kotlin die Überladung von Operatoren unterstützt.

Operator overloading

Jeder Typ, der das Standard Java Interface Comparable<T> implementiert, kann in Kotlin mit den Operatoren <, >, ==, <=, >= verglichen werden. Das bedeutet, dass man auch benutzerdefinierte Typen wie KDay vergleichen kann, wenn man compareTo vom Interface implementiert:

CODE
data class KDay(val rawValue: Int): Comparable<KDay> {  
    override operator fun compareTo(other: KDay): Int {  
        return rawValue.compareTo(other.rawValue)  
    }
    // ...
}

fun test(){
    KDay(0) > KDay(1) // false
    KDay(42) <= KDay(42) // true
}

Die Überladung von Operatoren in Kotlin erfolgt mithilfe des Schlüsselworts operator und eines semantischen Namens wie compareTo oder plus. Das Implementieren von + sieht folgendermaßen aus:

CODE
data class KDay(val rawValue: Int): Comparable<KDay> {  
    // ...
    operator fun plus(other: KDay): KDay {  
        return KDay(this.rawValue + other.rawValue)  
    }
    // ...
}

// and somewhere else
fun totalRentedDays(rental1: KRental, rental2: KRental): KDay {  
    return rental1.daysRented + rental2.daysRented  
}

Man kann diese Funktion auch von Java aus aufrufen:

CODE
var oneDay = new KDay(0);
var otherDay = new KDay(1);
var result = oneDay.plus(otherDay);

Wie funktioniert das auf der JVM? Operatoren sind reguläre Methoden einer Klasse. Wenn man den operator plus() definiert, erhält die Klasse KDay eine Methode plus(). Zusätzliche Metadaten im Bytecode deklarieren plus() gegenüber anderem Kotlin-Code als operator.

Nun hat die Überladung von Operatoren allerdings einen schlechten Ruf. Java unterstützt sie bekanntermaßen nicht, da James Gosling (der Schöpfer von Java) aus C++ wusste, dass sie leicht missbraucht werden können (sie könnten in Java jedoch mit Project Valhalla zurückkehren, da sie zum Vergleichen primitiver Werttypen nützlich sind).

Ich denke, es funktioniert in Kotlin gut, weil man nicht direkt den Operator überlädt, sondern einen semantischen Namen. Man überlädt div, nicht /. Das bietet einen semantischen Test: man kann den Code aussprechen und prüfen, ob er Sinn ergibt. Miete geteilt durch Miete ergibt keinen Sinn und sollte keine Operator-Überladung verwenden. Tag plus Tag ist hingegen ein Kandidat.

Ein Aspekt der Operator-Überladung ist, dass == der .equals-Methode in Java entspricht. In Kotlin vergleicht man also immer mit dem doppelten Gleichheitszeichen, selbst wenn man Java-Records vergleicht:

CODE
fun isSame(oneDay: JDay, otherDay: JDay): Boolean {  
    return oneDay == otherDay // works even for comparing J(ava) Day objects
}

Auch bei Listen und Maps baut Kotlin auf Standard Java Methoden auf. List<>.contains beziehungsweise Map<>.containsKey wird in Kotlin zum Operator in bzw. !in. In Kotlin kann man also schreiben:

CODE
fun isReturned(
    rentalId: KRentalId, 
    allRentals: Map<KRentalId, KRental>
): Boolean {  
    return rentalId !in allRentals  
    // equivalent of:
    // !allRentals.containsKey(rentalId)
}

Ich finde, dass sich „rentalId is not in allRentals“ besser liest als das Java-Äquivalent „not allRentals contains rentalId“. Der gleiche Vorteil gilt auch für die anderen Operatorüberladungen. Die Operatoren sorgen für übersichtlicheren und klareren Code, vorausgesetzt, sie sind semantisch sinnvoll.

Die Operatoren zeigen auch, wie die Interoperabilität mit Java gehandhabt wird. Wo immer möglich, baut die Sprache auf den bestehenden Sprachfunktionen auf und macht nicht „ihr eigenes Ding“. Dies gilt auch für die Art und Weise, wie die Sprache mit Unveränderlichkeit umgeht.

Dies deckt die gängigsten Interoperabilitätssituationen ab. Es gibt natürlich auch Funktionen, die sich nicht so gut zwischen den Sprachen abbilden lassen, aber für Anwendungsentwickler kommen sie nicht oft vor. Ein Beispiel ist, dass Kotlin keine static-Methoden hat. Das erzeugt Probleme, wenn ein Framework aber statische Methoden erwartet. Dann muss man in Kotlin eine Funktion in einem so genannten companion object deklarieren und die Funktion als @JvmStatic markieren. Ein weiteres Beispiel sind Bibliotheken zum Serialisieren von Daten, die Kotlin nicht unterstützen. Diese beachten dann nicht die Kotlin-Annotationen zur Nullsicherheit (siehe Teil 4). Als Bibliotheksentwickler gibt es noch weitere Situationen zu beachten, die jedoch den Rahmen dieses Artikels sprengen würden.

Unveränderlichkeit

Programmiersprachen lassen sich sicherer gestalten, indem sie Unveränderlichkeit (Immutability) unterstützen, insbesondere die Unveränderlichkeit von Datenreferenzen. Das heißt: Wenn man Daten verändert, erhält man eine neue Referenz zurück, während Code, der die alte Referenz verwendet, keine Änderungen wahrnimmt. Collections in der Standardbibliothek von Clojure (einer anderen JVM-Sprache) geben beispielsweise bei einer Änderung immer eine effiziente und performante Kopie zurück. Das erleichtert die Analyse des Codes, da separate Codeelemente die Daten der anderen nicht verändern können und man so nur einen Kontrollfluss analysieren muss, nicht alle Verzweigungen.

Interessanterweise ist Unveränderlichkeit nicht nur für Programmierer von Vorteil, sondern auch für die JVM. Sind alle Daten veränderbar, kann man entweder keine aggressiven Bytecode-Optimierungen durchführen (z. B. Escape-Analyse, Constant Folding) oder muss regelmäßig prüfen, ob die Optimierungen für die aktuellen Daten noch gültig sind. Dies ist in hochgradig parallelisierten Anwendungen, in denen verschiedene (virtuelle) Threads potenziell die Daten der anderen verändern können, noch problematischer.

Es ist daher keine Überraschung, dass Java records eingeführt hat, bei denen alle Konstruktorparameter final sind, was bedeutet, dass sie nicht neu zugewiesen werden können. Bei primitiven Typen bedeutet dies, dass die Werte ebenfalls unveränderlich sind. Bei Referenztypen (Klassen, Records, Sammlungen) hängt die tatsächliche Unveränderlichkeit von (1) den final-Garantien ab, die die JVM bietet, und (2) der Implementierung dieses Typs.

  1. Eine class mit einem Feld private final int number kann tatsächlich über Deep Reflection neu zugewiesen werden (siehe “Destructoring ist die Zukunft von Java’s Encapsulation”), da final auf der JVM (derzeit) nicht immer final bedeutet.
  2. Ein Record mit einem nicht neu zuweisbaren Parameter vom Typ List<T> ist selbst nicht unveränderlich, da sein Parameter List<T> unter anderem mit der Methode add() verändert werden kann.

Kotlin kann beides nicht ändern, da es auf der JVM läuft. Es bietet jedoch Unterstützung bei Collections an.

Collections

Häufig kommt es vor, dass man an einer Stelle eine List erstellen und diese an andere Stellen im Code weitergeben möchte. Dabei darf der Inhalt nicht verändert werden. In Java kann man solche unveränderlichen Collections mit List.of oder Collections.unmodifiableList(aList) erstellen.

Zur Laufzeit erfüllen sie auch ihren Zweck. Jede Änderung an einer solchen Liste führt zu einer UnsupportedOperationException. Dies kann jedoch zu einigen überraschenden Situationen führen, da im Typsystem nichts darauf hinweist, dass man die Liste nicht ändern darf. Die Methoden add, remove usw. sind weiterhin vorhanden, auch wenn die Collection eigentlich unmodifiable ist.

CODE
List<String> aList = List.of("a", "b"); 
// ... pass list to other places in the code
aList.add("c");
//    ^^^
//    still exists and will throw at runtime

Kotlin verfolgt einen anderen Ansatz und trennt direkt im Interface Lese- und Schreiboperationen voneinander. In Kotlin gibt es:

  • List mit Leseaktionen wie get
  • MutableList mit mutierenden Aktionen wie add

In Java werden beide der Schnittstelle java.util.List zugeordnet, sodass alle Aktionen wieder sichtbar sind, sobald man sie an Java-Code übergibt. In Kotlin profitiert man nun jedoch zusätzlich zur Laufzeitsicherheit auch von der Sicherheit zur Kompilierungszeit:

CODE
val immutableList: List<String> = listOf("a", "b")  
// ... pass list to other places in the code
immutableList.add("c")
//            ^^^
//            does not compile

Wenn man eine veränderbare Liste haben will, muss man das ausdrücklich modellieren:

CODE
val mutableList: MutableList<String> = mutableListOf("a", "b") // <1>
// ... pass list to other places in the code
mutableList.add("c") // compiles

Nutzt man für die Erstellung mutableListOf <1>, ist das Ergebnis eine Liste vom Typ MutableList, die über die mutierende Methode add verfügt.

Da MutableList List erweitert, kann man sogar ein mutableListOf() übergeben, wo eine List erforderlich ist. In diesem Fall hindert dann allerdings nichts den Kotlin-Code daran, die List wieder in eine MutableList zu casten und die add()-Methode zu verwenden.

Wie funktioniert das auf der JVM? Der Bytecode zeigt sowohl für immutableList als auch für mutableList reguläre java/util/List-Typen an. Der Kotlin-Compiler speichert die zusätzlichen Typinformationen im Binärformat mit einer @Lkotlin/Metadata-Annotation am Anfang der Klassendatei, sodass anderer Kotlin-Code weiß, dass die erste Liste unveränderlich ist. Das Annotationsformat scheint Protobuf zu sein und kann mit der Bibliothek kotlin-metadata-jvm geparst werden. Zur Laufzeit ist die immutableList tatsächlich unveränderlich – sogar für Java-Code –, da listOf() java.util.Arrays$ArrayList zurückgibt, das eine feste Größe hat und beim Aufruf von add() einen Fehler auslöst. mutableListOf() gibt eine java.util.ArrayList zurück, die über eine implementierte add()-Methode verfügt.

Zusammenfassung: Eine Sprache für Interoperabilität

Sollte man sich für Java oder Kotlin entscheiden? Warum nicht beides? Im ersten Abschnitt konnte man sehen, dass die Syntax von Kotlin der von Java sehr ähnlich ist. Die meisten neuen Konzepte – Top-Level-Funktionen, Datenklassen, Properties – fühlen sich wie natürliche Erweiterungen an. Der Abschnitt über Interoperabilität befasste sich dann eingehend damit, wie Kotlin mit der Java-Plattform zusammenarbeitet. Gezeigt wurde, wie auch neue Konzepte im Bytecode mit regulären class-Elementen und (static) Methoden abgebildet werden. Der Abschnitt über Unveränderlichkeit ging darauf ein, wie Kotlin unveränderliche Collections nutzen kann, obwohl es die Standardbibliothek von Java verwendet.

Kurz gesagt: Es gibt kaum technische Hindernisse, die einen davon abhalten, Kotlin in einem bestehenden Java-Projekt einzusetzen und nach einer Evaluationsphase entweder situativ mehr Code nach Kotlin zu migrieren oder komplett zu Java zurückzukehren. „Warum nicht beides?“ ist eine echte Option, entweder um in ausgewählten Modulen von Kotlins Modellierungs-Fähigkeiten zu profitieren oder als iterativer Migrationspfad für große Codebasen. Aber sind diese Modellierungs-Fähigkeiten wirklich so viel besser als in Java? Diese Frage ist Kernthema von Teil 4 und der Abschluss dieser Artikelserie.