Für die meisten Projekte im Bereich der serverseitigen Softwareentwicklung zieht Cloudflight Kotlin gegenüber Java vor. Dafür gibt es eine Vielzahl von Gründen, von denen ich einige in diesem Blogbeitrag beschreiben werde.

Ein Plain Old Java Object aus Kotlin-Sicht

Bei der Übertragung von Daten von einem Ort zum anderen mit Java ist es üblich, ein Plain Old Java Object (POJO) zu verwenden. Normalerweise ist das ein Objekt mit einigen Eigenschaften und Accessor-Methoden.

Kotlin bietet Möglichkeiten, die Menge des in diesen Objekten verwendeten Codes zu reduzieren. In diesem Beispiel nehmen wir ein POJO und wandeln es in eine Kotlin-Klasse um.

Plain Old Java Objects

Beginnen wir mit einem UserDto. Zunächst enthält es nur wenige Felder und Accessor-Methoden für diese Felder. Es könnte wie folgt aussehen:

public class UserDto {
    private Integer id;
    private String name;
    private String email;
    public Integer getId() {
        return id;
    }
    public void setId(Integer id) {
        this.id = id;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getEmail() {
        return email;
    }
    public void setEmail(String email) {
        this.email = email;
    }
}

Wenn ihr IntelliJ IDEA verwendet und diesen Code in eine Kotlin-Datei kopiert, erhaltet ihr eine Benachrichtigung, ob der Java-Code in Kotlin-Code konvertiert werden soll. Nach dem Klicken auf „Ja” erhalten wir folgenden Code:

class UserDto {
    var id: Int? = null
    var name: String? = null
    var email: String? = null
}

Das Kotlin-Code-Stück ist 25 Zeilen kürzer als der Java-Code. Alle Accessor-Methoden können durch die Syntax von Kotlin weggelassen werden.

Konstruktoren

Um eine vollständig initialisierte Kopie des zuvor definierten Java-Objekts instanziieren zu können, müssten wir einen Konstruktor hinzufügen. Wenn wir einen für alle Argumente erstellen würden, würde er wie folgt aussehen:

public class UserDto {
    public UserDto(
        id: Integer,
        name: String,
        email: String
    ) {
        this.id = id;
        this.name = name;
        this.email = email;
    }
    ...
}

Das Hinzufügen des Konstruktors in Java würde mehr Code hinzufügen. Wenn wir der Klasse mehr Eigenschaften hinzufügen, müssten wir auch den Konstruktor aktualisieren.

Auf der Kotlin-Seite können wir unsere Klasse wie folgt aktualisieren, um einen Konstruktor hinzuzufügen:

class UserDto(
    var id: Int? = null,
    var name: String? = null,
    var email: String? = null
)

Durch das Ersetzen der geschweiften Klammern durch runde Klammern haben wir einen Konstruktor zur Klasse hinzugefügt, ohne eine Zeile Code hinzuzufügen. Beide Konstruktoren werden sowohl in Java- als auch in Kotlin-Code auf dieselbe Weise aufgerufen.

Equals, Hashcode, ToString

Üblicherweise verwenden Java-Klassen auch einige Boilerplate-Methoden. Das würde unserer UserDto-Klasse mehr Code hinzufügen:

public class UserDto {
    ...
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        UserDto userDto = (UserDto) o;
        return id.equals(userDto.id) && name.equals(userDto.name) && email.equals(userDto.email);
    }
    @Override
    public int hashCode() {
        return Objects.hash(id, name, email);
    }
    @Override
    public String toString() {
        return "UserDto{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", email='" + email + '\'' +
                '}';
    }
}

Ähnlich wie beim Konstruktor müssten wir bei der Aktualisierung unserer Klasse auch diese Methoden aktualisieren.

Kotlin bietet auch hierfür eine Lösung mit nur einer Zeilenänderung. Kotlin führt Datenklassen ein. Datenklassen definieren für uns eine Handvoll zusätzlicher Methoden (equals, hashCode, toString und copy). Wenn wir eine Datenklasse verwenden würden, um uns diese Methoden zu geben, würde unsere Klasse wie folgt aussehen:

data class UserDto(
    var id: Int? = null,
    var name: String? = null,
    var email: String? = null
)

Aber all dieser Code kann von jeder modernen IDE generiert werden. Warum sollte ich dafür Kotlin verwenden?

Die resultierende Java-Version hat satte 63 Zeilen Code – und 63 Zeilen Wartung. Die Kotlin-Version hingegen hat nur 5 Zeilen. Das führt zu einer Codebasis, die einfacher zu navigieren ist, weniger Überraschungen bereithält und leichter zu pflegen ist.

Ich habe einen kurzen Blick auf ein relativ kleines Projekt geworfen und 35 ähnliche Klassen wie die frisch refaktorisierte gefunden. Ich schätze, dass die Verwendung von Kotlin in diesem Projekt 4.000 Zeilen Code einsparen würde – und das nur bei POJOs.

Sicherer und ausdrucksstärkerer Code

Der folgende Abschnitt refaktorisiert eine Funktion von einer Java-ähnlichen Funktion in eine Kotlin-Funktion und verwendet dabei Features, die Kotlin bietet, um sicheren und ausdrucksstärkeren Code zu schreiben.

Zunächst definieren wir einen einfachen Baum. Ein Baum ist entweder ein Zweig mit mehreren Baumelementen oder ein Blatt mit einem Wert. Hier ist die Java-Version:

interface Tree<T> {
}
class Branch<T> implements Tree<T> {
    public Branch(List<Tree<T>> nodes) {
        this.nodes = nodes;
    }
    List<Tree<T>> nodes;
    public List<Tree<T>> getNodes() {
        return nodes;
    }
}
class Leaf<T> implements Tree<T> {
    public Leaf(T value) {
        this.value = value;
    }
    T value;
    public T getValue() {
        return value;
    }
}

In Kotlin würde es wie folgt aussehen:

interface Tree<T>
class Branch<T>(val nodes: List<Tree<T>>) : Tree<T>
class Leaf<T>(val value: T) : Tree<T>

Als Nächstes schreiben wir eine Funktion, die wir in Kotlin refaktorisieren werden. Für dieses Beispiel habe ich eine Funktion erstellt, um alle Zahlen im Baum zu summieren. In Java könnte man es so machen:

class TreeUtil {
    static Integer sumTree(Tree<Integer> tree) {
        Integer count;
        if (tree instanceof Branch) {
            var branch = (Branch<Integer>) tree;
            count = 0;
            for (var node : branch.getNodes()) {
                count += sumTree(node);
            }
        } else if (tree instanceof Leaf) {
            var leaf = (Leaf<Integer>) tree;
            count = leaf.getValue();
        } else {
            throw new IllegalArgumentException("Unknown variant");
        }
        return count;
    }
}

Das direkte Übersetzen in Kotlin ergibt Folgendes:

fun sumTree(tree: Tree<Int>): Int {
    var count: Int
    if (tree is Branch) {
        val branch = tree as Branch
        count = 0
        for (node in branch.nodes) {
            count += sumTree(node)
        }
    } else if (tree is Leaf) {
        val leaf = tree as Leaf
        count = leaf.value
    } else {
        throw IllegalArgumentException("Unknown variant")
    }
    return count
}

Wenden wir nun einige der Kotlin-Features an, um diese Funktion lesbarer und ausdrucksstärker zu machen.

Ausdrücke

Ein häufiges Muster in Java ist die Definition einer auf null gesetzten Variable, die während einer if-Anweisung aktualisiert wird. In Kotlin sind if-Anweisungen Ausdrücke – das erlaubt es uns, Werte aus der if-Anweisung zurückzugeben, anstatt die Variable innerhalb der if-Anweisung zu verändern. Damit lässt sich die Methode wie folgt refaktorisieren:

fun sumTree(tree: Tree<Int>): Int = if (tree is Branch) {
    val branch = tree as Branch
    var count = 0
    for (node in branch.nodes) {
        count += sumTree(node)
    }
    count
} else if (tree is Leaf) {
    val leaf = tree as Leaf
    leaf.value
} else {
    throw IllegalArgumentException("Unknown variant")
}

Anstatt einen Wert zurückzugeben, können wir die if-Anweisung als Ganzes zurückgeben, was den Code weniger unübersichtlich macht.

Smart Casting

Das Typumwandeln eines Objekts in Java erfordert zwei Schritte. Zunächst muss geprüft werden, ob der Typ mit dem erwarteten übereinstimmt, dann muss der Wert in einen anderen Typ umgewandelt werden. Kotlin führt hier Smart Casting ein. Nach der Typprüfung kennt der Compiler bereits den tatsächlichen Typ, sodass wir den Cast weglassen können. Mit Smart Casting sieht unser Code so aus:

fun sumTree(tree: Tree<Int>): Int = if (tree is Branch) {
    var count = 0
    for (node in tree.nodes) {
        count += sumTree(node)
    }
    count
} else if (tree is Leaf) {
    tree.value
} else {
    throw IllegalArgumentException("Unknown variant")
}

Nach der Typprüfung wird tree implizit zu einem Branch oder einem Leaf, was es uns ermöglicht, den unsicheren Typecast zu entfernen und direkt auf die Eigenschaften der tree-Variable zuzugreifen.

Pattern Matching

Kotlin erlaubt uns, auf einen Typ zu matchen; damit können wir den Typ prüfen, ohne einer if-Anweisung weitere Fälle hinzuzufügen. Der Code lässt sich so refaktorisieren:

fun sumTree(tree: Tree<Int>): Int = when (tree) {
    is Branch -> {
        var count = 0
        for (node in tree.nodes) {
            count += sumTree(node)
        }
        count
    }
    is Leaf -> tree.value
    else -> throw IllegalArgumentException("Unknown variant")
}

Die when-Anweisung in Kotlin ist ähnlich wie switch in Java, bietet aber einige Superkräfte im Vergleich zu switch – hauptsächlich, dass eine Bedingung für jeden Zweig definiert werden kann, anstatt nur Gleichheit zu prüfen.

Sealed Classes

Kotlin führt Sealed Classes ein. Alle Klassen, die eine Sealed Class implementieren, müssen sich im selben Modul befinden wie die Sealed Class selbst. Wir können unseren Tree wie folgt zu einer Sealed Class machen:

sealed interface Tree<T>

Jetzt kennt der Compiler alle möglichen Typen, die ein Baum sein könnte, und erlaubt uns, die else-Klausel aus der when-Anweisung sicher zu entfernen:

fun sumTree(tree: Tree<Int>): Int = when (tree) {
    is Branch -> {
        var count = 0
        for (node in tree.nodes) {
            count += sumTree(node)
        }
        count
    }
    is Leaf -> tree.value
}

Wenn wir den sealed-Modifier entfernen würden, kompiliert das Programm nicht, weil das Pattern Matching nicht erschöpfend ist. Da nur zwei Versionen von Tree möglich sind, müssen wir nur auf diese Typen prüfen.

Funktionale Abstraktion

Um den letzten Teil des Java-Codes zu bereinigen, könnten wir die Iteration aller Knoten im Zweig in etwas Lesbareres umschreiben:

fun sumTree(tree: Tree<Int>): Int = when (tree) {
    is Branch -> tree.nodes.sumOf { node -> sumTree(node) }
    is Leaf -> tree.value
}

Die sumOf-Methode ist eine Standardbibliotheksmethode, die ein Iterable in einen Int umwandelt. Wenn wir sie selbst schreiben würden, könnte sie so aussehen:

fun <T> Iterable<T>.sumOf(operation: (T) -> Int): Int {
    var sum = 0
    for (item in this) {
        sum += operation(item)
    }
    return sum
}

Einige Dinge passieren hier:

  • Wir definieren eine Erweiterungsmethode – obwohl Iterable nicht in unserer Klasse ist, können wir Funktionen dafür schreiben. Das ist in Java überhaupt nicht möglich.
  • Der Parameter operation ist eine Funktion, die Typ T erwartet und einen Int zurückgibt.

Mit den von Kotlin bereitgestellten Tools wird die Java-ähnliche Funktion in 4 Zeilen ausdrucksstarken Kotlin-Code umgeschrieben.

Weitere Features

Im Folgenden einige weitere Features von Kotlin.

Null-Sicherheit zur Kompilierzeit

Eines der wichtigsten Verkaufsargumente für Kotlin ist die Null-Sicherheit zur Kompilierzeit. Damit eine Variable null sein kann, muss sie als nullable definiert werden. Nullable Variablen müssen behandelt oder geprüft werden. Das ist eine der wichtigsten Fehlerquellen in Java-Systemen.

In Kotlin kann ein Parameter wie folgt als nullable zugewiesen werden:

fun sumTwoNumbers(n1: Int?, n2: Int?): Int {
    return n1 + n2 // Will not compile because n1 or n2 could be null
}

Dieses Beispiel wird nicht kompilieren. Sowohl n1 als auch n2 könnten null sein. Um es zu kompilieren, müssen einige Prüfungen hinzugefügt oder ein anderes Verhalten für Null-Werte definiert werden. Mit Smart Casting können wir die Werte sicher addieren:

fun sumTwoNumbers(n1: Int?, n2: Int?): Int {
    if (n1 == null || n2 == null) throw Exception("Invalid value passed")
    return n1 + n2
}

Interoperabilität mit Java

Kotlin wurde entwickelt, um vollständig kompatibel mit JVM-Java zu sein. Das erlaubt es uns, Logik oder Strukturen entweder in Kotlin- oder Java-Code zu definieren und sie aus beiden aufzurufen. Ich nehme das zuvor erstellte UserDto als Beispiel:

data class UserDto(var id: Int, var name: String, var email: String)

Mit korrekt konfiguriertem Gradle oder Maven können wir diesen Code auf der Java-Seite idiomatisch aufrufen. Es würde wie folgt aussehen:

void main() {
    var user = new UserDto(1, "Cloudflight", "info@cloudflight.io");
    System.out.println(user.toString()); // UserDto(id=1, name=Cloudflight, email=info@cloudflight.io)
    System.out.println(user.getId()); // 1
}

Bevorzugte Unveränderlichkeit

Java enthält das Schlüsselwort final, das eine Variable daran hindert, neu zugewiesen zu werden. Ein kleines Beispiel:

void main() {
    var someMutableValue = "The first value";
    someMutableValue = "The value is updated"
    final var someImmutableValue = "The first value"
    someImmutableValue = "Will not compile now"
}

Das final-Schlüsselwort fügt dem Code Rauschen hinzu. Kotlin hat einen viel subtileren Ansatz dafür:

fun main() {
    var someMutableValue = "The first value";
    someMutableValue = "The value is updated"
    val someImmutableValue = "The first value"
    someImmutableValue = "Will not compile now"
}

Kotlin hat zwei Schlüsselwörter zur Definition von Variablen: val und var. val wird für unveränderliche Variablen verwendet, var für veränderliche.

Dieses Muster findet sich auch in der Kotlin-Standardbibliothek.

Aus historischen Gründen haben die meisten Collections in Java eine Methode namens add, die ein Element zur Collection hinzufügt. Das gilt auch für unveränderliche Listen. Diese werfen normalerweise zur Laufzeit eine UnsupportedOperationException, wenn sie aufgerufen werden.

void main() {
    var list = new ArrayList();
    list.add(1);
    list.add(2);
    var immutableList = List.of();
    immutableList.add(1); // Throws an UnsupportedOperationException at runtime
}

In Kotlin sind Collections standardmäßig unveränderlich. Um eine veränderliche Liste zu erstellen, sollte mutableListOf aufgerufen werden, und um eine unveränderliche Liste zu erstellen, wird listOf aufgerufen. Eine unveränderliche Liste bietet keine Möglichkeit, Elemente hinzuzufügen, was Laufzeitfehler verhindern kann.

fun main() {
    var list = mutableListOf()
    list.add(1)
    list.add(2)
    var immutableList = listOf()
    immutableList.add(1) // Will not compile here
}

Interne Sichtbarkeit

Kotlin führt ein neues Sichtbarkeitsschlüsselwort ein: internal. Das erlaubt es, Code in folgenden Fällen sichtbar zu machen:

  • Der interne Code kann innerhalb desselben Maven-Projekts aufgerufen werden.
  • Der interne Code kann innerhalb desselben Gradle-Source-Sets aufgerufen werden.
  • Der interne Code kann von Code aufgerufen werden, der mit demselben kotlinc-Aufruf kompiliert wurde.

Das hilft bei der Modularisierung von Codebasen bei der Arbeit mit separaten Modulen.

Klärung einiger Risiken

Es gibt einige häufig genannte Punkte, warum der Wechsel zu Kotlin eine schlechte Idee sei. Dieser Abschnitt erklärt, warum diese Punkte nicht so stark sind, wie allgemein angenommen wird.

Ist es möglich, Kotlin-Entwickler zu finden?

Laut der Stack-Overflow-Entwicklerumfrage 2022 arbeiten 9,2 % der Entwickler mit Kotlin, verglichen mit 33,27 %, die mit Java arbeiten. Die Umfrage zeigt auch, dass mindestens 10 % dieser Entwickler lieber mit Kotlin als mit Java arbeiten möchten.

Einen Java-Entwickler in einen Kotlin-Entwickler zu verwandeln ist einfach. Kotlin zu schreiben ist dem Schreiben von Java sehr ähnlich. Beide werden normalerweise auch in derselben Umgebung sowohl für die Entwicklung als auch für die Ausführung betrieben. Zusammen mit der besseren Code-Sicherheit von Kotlin kann ein Java-Entwickler schnell guten Kotlin-Code schreiben.

Ist Kotlin zukunftssicher?

Auf der I/O 2019 kündigte Google an, dass Kotlin die bevorzugte Programmiersprache für alle Android-Apps sein wird. Das zeigt, dass Google sich sicher ist, dass Kotlin bleibt – und zwar noch lange.

Laut Google verwenden 80 % der Top-1000-Android-Apps Kotlin als Programmiersprache.

Fazit

Das sind also einige der Gründe, warum Cloudflight (und andere Unternehmen) Kotlin gegenüber Java für die serverseitige App-Entwicklung bevorzugen.

Quellen

  • Android-Entwickler-Blog
  • Stack-Overflow-Entwicklerumfrage 2022
  • Kotlin-Dokumentation