Über unsMediaKontaktImpressum
Lovis Möller 27. Februar 2018

Ist Kotlin das bessere Java? Eine Einführung.

Wer sich mit JVM-Sprachen beschäftigt, der kommt um Kotlin eigentlich nicht mehr herum. Bei der Androidentwicklung schon defacto-Standard, wird die von JetBrains entwickelte Sprache nun auch mehr und mehr in anderen Bereichen eingesetzt. Ein Hauptgrund ist sicher die fast perfekte Interoperabilität mit Java, aber auch das gute Tooling und natürlich die modernen Features der Sprache selbst lassen die Kotlin-Fangemeinde immer weiter wachsen. In diesem Artikel sollen die größten Verbesserungen gegenüber Java einmal näher untersucht werden.

In der Softwareentwicklung geht es in erster Linie darum, die Probleme des Kunden auf möglichst schnelle aber gleichzeitig sichere Art und Weise zu lösen. Programmiersprachen sind dabei nur Werkzeuge und die beste Lösung ist sowieso die, die gar keinen Code benötigt.

Als Java vor über 20 Jahren auf den Markt kam, haben die Entwickler der Sprache genau dieses Versprechen gegeben und auch weitgehend eingehalten. Wie James Gosling damals schrieb: "Java is a blue collar language. It’s not PhD thesis material but a language for a job. [1]" Aber die Anforderungen und Vorlieben der Entwicklercommunity ändern sich. Während z.B. Checked Exceptions, primitive Typen oder null vor 20 Jahren noch als gute Ideen galten, sieht man das heutzutage anders. Auch die Menge an Code, die man mit Java schreiben muss, wirkt mittlerweile nicht mehr zeitgemäß (Mit Java7 (Diamond Operator) und Java8 (Lambdas) hat sich das schon gebessert, für Java10 ist auch Type-Inference für lokale variablen geplant). Genau hier kommt Kotlin ins Spiel.

Kotlin: Null als Teil des Typsystems

Kotlin macht vieles besser als Java. Zunächst kann man grob sagen, dass eine Klasse in Kotlin aus ca. 20-30 Prozent weniger Codezeilen besteht, ohne dabei schlechter lesbar zu sein. So sind z. B. explizite Typdeklarationen und Semikolons optional, Lambdas – und funktionale Konzepte im Allgemeinen – fügen sich besser ins Gesamtbild ein. Getter und Setter werden vom Compiler generiert. Weiter bringt Kotlin viele Dinge von Haus aus mit, die man sich bisher mit Libraries wie Lombok als externe Dependency ins Projekt holen musste. So werden z. B. für Klassen, die mit data markiert sind, dazu auch noch brauchbare toString()-, equals()-, hashCode()- und copy()-Methoden generiert.

Außerdem gehören mit Kotlin NullPointerExceptions weitgehend der Vergangenheit an. In Kotlin ist null nämlich Teil des Typsystems. Parameter, Properties oder Rückgabewerte sind entweder nullable, oder nicht. Wenn sie es nicht sind, verhindert der Compiler, dass null hineingegeben, gesetzt oder zurückgegeben wird. Diese optionalen Typen sind mit einem ? gekennzeichnet.

var text: String = “Hallo, Kotlin!” // dieser Text kann niemals auf “null” gesetzt werden
var nullableText: String? = null // dieser schon

//paramter ist nullable, aber return value nicht -> null kann nicht zurückgegeben werden
fun addDoubles(left: Double, right: Double?) : Int {
  if(right == null) {
    return left.toInt()
 } else {
   return left.toInt() + right.toInt()
 }
}

Nullchecks lassen sich häufig auch komplett vermeiden, indem man den aus Groovy bekannten Safe-Call-Operator ?. verwendet. Wenn das Objekt, dessen Methode aufgerufen wird, nullable ist, erzwingt es der Compiler sogar, diesen Operator anstatt des regulären Access-Operator . zu benutzen.

var nullableText: String? = null
var reversedText: String? = nullableText?.reversed()

Wenn der text in diesem Beispiel tatsächlich null ist, wird der gesamte Ausdruck als null ausgewertet und der reversedText ist ebenfalls null. Den Typ der Variable ohne ? zu definieren wäre daher an dieser Stelle gar nicht möglich gewesen.

Dieser Operator lässt sich auch mehrfach hintereinander verwenden:

val name = person?.name?.toUpperCase()

Neben dem Safe-Call-Operator existiert auch noch der Elvis-Operator, mit dem schnelle Null-Oder-Default-Abfragen gemacht werden können:

val name = person.name ?: “Unknown”

Ist person.name = null, so wird der Variablen name der Wert Unknown zugewiesen.

Anders als bei den JSR-305 Annotationen (@Nullable, @NonNull), ist es in Kotlin überhaupt nicht möglich null zu verwenden, wenn dies nicht explizit angegeben ist. Ein Typ, der den Wert null annehmen kann, ist ein anderer Typ als das nicht-nullable Gegenstück. Wenn ein String als Parameter erwartet wird, kann kein String? hineingegeben werden. Wenn allerdings ein String? erwartet wird, ist es trotzdem möglich, einen String zu verwenden. Daher ist es angenehmer, mit Kotlins ? zu arbeiten, als beispielsweise mit einem Optional. Es ist nicht notwendig, Werte extra in ein Optional zu wrappen, um sie an die API anzupassen.

Der Safe-Call-Operator ?. ist besonders in Kombination mit der let-Funktion aus der Standard-Library nützlich, da diese so als Alternative zum klassischen null-Check verwendet werden kann:

nullableText?.let ({
 // Dieser Block wird nur ausgeführt, wenn `nullableText` nicht `null` ist
})

Die geschweiften Klammern in der Parameterliste bedeuten, dass an dieser Stelle ein Lambda steht. ( ()-> {...} in Java). Wenn der letzte Parameter eine Funktion ist, können – und sollten – die typischen runden Klammern des Funktionsaufrufs weggelassen werden:

nullableText?.let {
...
}

Die let-Funktion nimmt als einzigen Parameter eine Funktion. Sie ist also eine Higher-Order-Function mit einem Parameter vom Typ (T) -> R. Funktionen sind in Kotlin eigenständige Typen, SAM-Interfaces wie in Java werden nicht benötigt. Der Parameter R ist in diesem Fall der nullableText selbst und liefert als Ergebnis Unit zurück, was in etwa void  in Java entspricht. Eventuelle Parameter eines Funktionstyp werden einfach innerhalb der Klammern vor dem Pfeil -> geschrieben. So ist z. B. (String) -> Int eine Funktion, die einen String akzeptiert und ein Int zurückliefert.

Smart-Casts in Kotlin: Intelligenter Casten

Betrachtet man die Beispielfunktion addDoubles() weiter oben, fällt auf, dass bei right.toInt() der Safe-Call-Operator gar nicht genutzt wird, obwohl der Parameter nullable ist. Der Compiler beschwert sich in diesem Fall aber trotzdem nicht.
Das liegt an einem weiteren Feature von Kotlin, den sogenannten Smart-Casts: Der Compiler weiß durch den vorausgehenden Nullcheck, dass right nachfolgend nicht mehr null sein kann und castet ihn implizit von String? zu String. Sehr praktisch, dass das nicht nur mit null, sondern mit allen Arten von Casts funktioniert!

override fun equals(other: Any?): Boolean {
  if (this === other) return true

  //is = das Kotlin Äquivalent zu instanceof
  if (other !is Message) return false

  // in java müsste ich explizit casten:
  // return this.id == ((Message)other).id`
  return this.id == other.id
 }

In dieser Implementierung der equals()-Methode einer Message-Klasse, wird other nach dem is-Check automatisch als Message behandelt. Um auf die ID zuzugreifen ist daher, anders als in Java, kein expliziter Cast notwendig. 

Immutability für den Alltag

Neben null-Sicherheit und Smart-Casts sorgt ein weiteres Feature für mehr Sicherheit im Programmieralltag: Kotlins Sprachdesign fördert und erleichtert Immutability. Das einfachste Beispiel ist die Erzeugung von nicht-veränderbaren Variablen mit val statt var. Obwohl final auch bei Java großzügig verwendet werden sollte, ist dies durch das zusätzliche Keyword zu viel Arbeit und demnach oft nicht der Fall.

Das wichtigere Beispiel für Immutability in Kotlin ist aber das Design der Collections-API. Anders als z. B. bei Scala werden mit Kotlin keine eigenen Collection-Klassen mitgeliefert. Die Java-Collections funktionieren einfach wie gewohnt weiter und werden durch eine Menge zusätzlicher Operationen ergänzt. Allerdings sehen die Interfaces und damit auch die Hierarchie der Collections etwas anders aus. Wenn ich versuche, zu einer Liste vom Typ List<String> ein neues Element hinzuzufügen wird das nicht funktionieren:

val words: List<String> = …
words.add(“Immutable”) // compile error

Die Interfaces List, Map und Set verfügen nämlich über keine Methoden, die das darunterliegende Objekt verändern können. Kotlin unterscheidet zwischen veränderbaren und read-only Collections (Technisch gesehen ist ein Map zwar gar keine Collection, fällt aber in eine ähnliche Kategorie von Datenstrukturen).

Um eine Collection mit Daten zu füllen, brauche ich eine Variable vom Typ MutableCollection, also z. B. eine MutableList.

val words: MutableList<String> = mutableListOf(...)
words.add(“mutable”) // works

Allerdings ist es selten notwendig, mit mutable-Collections zu arbeiten und diese direkt zu manipulieren, da alle Collections mit einem ganzen Satz an praktischen Funktionen erweitert worden sind. Das sind unter anderem die klassischen Listenoperationen, filter, map, reduce und fold. Aber auch speziellere wie filterIsInstance, groupBy, sumBy oder takeWhile.

Eine einfache Aufgabe könnte es sein, den kürzesten String in einer Liste herauszubekommen. In Java würde man das ungefähr so machen:

String shortest = items.stream()
.min(Comparator.comparing(item -> item.length()))
.get();

Während in Kotlin ein einfaches

val shortest = items.minBy { it.length }

ausreichend ist.

Noch eindrucksvoller wird diese – an vielen Stellen kürzere – Schreibweise, wenn das Problem etwas komplizierter wird. Möchte man z. B. die Namen aller Mitarbeiter einer bestimmten Gehaltsgruppe ermitteln, sähe der Aufruf in Java etwa so aus:

List<String> namesOfLevel1Employees = allEmployees.stream()
                 .filter(p -> p.getSalaryGroup() == SalaryGroup.L1)
                 .map(p -> p.getName())
                 .collect(Collectors.toList());

Und Kotlin:

val namesOfLevel1Employees = allEmployees
                          .filter { it.salaryGroup == SalaryGroup.L1 }
                          .map { it.name }

Dadurch, dass weder Streams noch Collectors nötig sind, spart man sich eine Menge Schreibarbeit, ohne dabei an Ausdruckskraft zu verlieren.

Bei all diesen Operationen wird immer eine neue Collection zurückgegeben. Da also die ursprüngliche Collection nicht selbst verändert wird, funktionieren die Operationen auch auf den read-only-Collections List, Set oder Map. Auf diese Art erleichtert Kotlin also den Umgang und die Verwendung von unveränderlichen Datenstrukturen. In der Praxis benötigt man eher selten eine veränderbare Collection. Die vielen vordefinierten Extension Functions für Collections helfen hier enorm. Außerdem kann ich im Interface meines Objekts immer List, Set oder Map als Rückgabewert angeben, obwohl intern eine veränderbare Collection verwendet wird.

Extension Functions für Collections

Wenn Kotlin intern dieselben Collections wie Java benutzt, dann stellt sich die Frage, wie es sein kann, dass diese so viel mehr Methoden anbieten als Java Collections. Die Antwort auf diese Frage lautet: Extension Functions.

Mit Extension Functions können Klassen um Funktionalität erweitert werden, ohne dass dabei auf Vererbung zurückgegriffen werden muss. Das ist vor allem dann nützlich, wenn man nicht selbst der Autor dieser Klassen ist. Die oben beschriebene filter-Funktion könnte dann z. B. so implementiert werden:

fun <T> List<T>.filter(predicate: (T) -> Boolean): List<T> {
 val destination = mutableListOf<T>()
  for (element in this) {
    if (predicate(element)) destination.add(element)
  }
  return destination
}

Da die Standard-Library diese Funktion für alle Collections bereits mitliefert, ist das wie gesagt nicht nötig. Am Beispiel kann man aber gut erkennen, wie Extension Functions definiert werden. Sie sehen aus wie normale Funktionsdefinitionen, nur, dass der Typ, der erweitert werden soll (in diesem Fall List<T>) zusammen mit einem Punkt dem Funktionsnamen vorangestellt wird.

Innerhalb der Funktion kann via this auf den so genannten Receiver zugegriffen werden. Der Receiver ist das Objekt, auf dem die Funktion später aufgerufen wird (List<T>). Ein großer Vorteil solcher Extensions ist, dass damit schlecht lesbare, statische Funktionen und ganze Utility-Klassen vermieden werden können.

Die am häufigsten verwendete Utility-Funktion ist wohl Collections.sort(). Auch diese wurde für Kotlin in eine Extension überführt. Anstatt also Collections.sort(myList, myComparator); aufrufen zu müssen, kann ich in einer viel natürlicheren Art und Weise mit dem Objekt arbeiten. Sortierung ist nun eine Operation auf der Liste selbst: myList.sortBy(myComparator). So verbessern Extension Functions auch den Lesefluss, was besonders bei komplexeren Konstrukten deutlich wird:

Text text = TextUtils.italic(TextUtils.capitalize("Hallo, " + TextUtils.underline(person.name)));

Diese Verkettung von statischen Funktionen muss von innen nach außen gelesen werden, um sie vollständig zu verstehen (so viele Klammern!). Das Gegenstück mit Extension Functions hingegen wird in derselben Reihenfolge angewendet, in der es auch geschrieben wurde (Mit ${property} lassen sich +-Ketten wie string1 + "," + string3 vermeiden – diese Technik nennt sich String-Interpolation):

val text: Text = "Hallo, ${person.name.underline()}".capitalize().italic()

Noch besser lesbar wird das Ganze, wenn man die Details wegabstrahiert und einen für die Domain passenden Namen wählt.
So wird aus dem Beispiel von oben, dem Filtern aller Mitarbeiter einer gewissen Gehaltsstufe, eine Extension Function für List<Employee>:

fun List<Employee>.collectLevel1EmployeeNames(): List<String> {
  return this .filter { it.salaryGroup == SalaryGroup.L1 }
                   .map { it.name }
}

Diese Funktion kann dann einfach im Code verwendet werden:

val names = allEmployees.collectLevel1EmployeeNames()

Extension Functions wirken auf den ersten Blick vielleicht ein bisschen wie schwarze Magie. Wie um alles in der Welt können denn Klassen (und Interfaces!) erweitert werden, ohne Vererbung einzusetzen? Wird vielleicht intern eine Art Wrapper erzeugt, der dann delegiert? Tatsächlich gibt es nichts magisches an Extensions, es handelt sich lediglich um einen einfachen Compilertrick: Der Bytecode für eine Extension entspricht dem Bytecode, der bei einer statischen Utility-Funktion erzeugt wird. Das heißt, dass das Employee-Beispiel in etwa dem folgendem Java-Code entspricht:

public static List<String> collectLevel1EmployeeNames(List<Employee> receiver) {
   return receiver.filter(....).map(....)
}

Extension Functions werden also zu statischen Utility-Funktionen compiliert, die einen zusätzlichen Parameter receiver besitzen. Dieser Receiver ist immer das Objekt auf dem ich die Extension aufgerufen habe.

Extension Functions sind überall

Natürlich sind Extensions nicht nur auf Collections beschränkt. Für jede Klasse und jedes Interface können solche Funktionen geschrieben werden. Die Standard-Library liefert z. B. das let, welches ich am Anfang des Artikels bereits beschrieben habe. Die let-Funktion erlaubt es, den Scope einer Variablen zu verändern. Wie oben erwähnt, ist das besonders hilfreich, wenn die Variable null sein kann:

var text: String? = ...
text?.let {
    println(it)
}

Die nullable Variable text wird hier in die Variable it überführt. Diese Variable kann nun nicht mehr null sein, da der gesamte Block ja nur ausgeführt wird, wenn text eben nicht null ist. Dieses it ist – wieder aus Groovy entliehen – eine Konvention in Kotlin. Hat ein Lambda nur einen Parameter, so muss man diesen nicht explizit angeben, sondern kann ihn stattdessen als it verwenden.

Eine weitere Besonderheit der let-Funktion ist, dass diese auch einen Wert zurückgeben kann.

val fullName = person?.let {
    “${it.firstName} ${it.lastName}”
}

Der letzte Ausdruck in diesem Lambda wird zurückgegeben (ohne return). In diesem Fall also ein String, der die beiden Properties firstName und lastName mit Hilfe von String-Interpolation konkateniert.

Neben let gibt es noch weitere Extensions dieser Art, zum Beispiel apply:

val redCar = Car().apply {
  this.color = Color.RED
  this.ps = 172
  doors = 3
}

Im Unterschied zu let wird bei apply kein it verwendet, sondern this. Dieses this kann, wie für this nun mal üblich, auch weggelassen werden (doors = 3). Was diese Funktion so nützlich macht, ist, dass sie den Receiver wieder zurückgibt, ohne, dass ich explizit return this schreiben muss. Das bedeutet für das Beispiel: Erst wird ein Car-Objekt erzeugt. Anschließend werden Farbe, PS und Anzahl der Türen gesetzt. Zuletzt wird genau dieses Car-Objekt zurückgegeben. Also wird der Variable redCar dann auch genau dieses Car-Objekt zugewiesen.

Viele Leute berichten, dass sie dank Kotlin wieder mehr Spaß am Programmieren haben.

Mit apply bleiben Initialisierungen eines Objekts dicht beieinander im selben Codeblock, selbst wenn die API ursprünglich nicht so konzipiert war. Da es sich bei diesem Codeblock um ein Lambda handelt, kann er auch in eine Variable ausgelagert und zu einem späteren Zeitpunkt verwendet werden:

val redCarConfig: Car.() -> Unit = {
  this.color = Color.RED
  this.ps = 172
  doors = 3
}

val newCar = Car().apply(redCarConfig)

Die apply-Funktion kann also als leichtgewichtige Alternative zum Builder Pattern gesehen werden, da Konstruktion und Repräsentation eines Objekts voneinander getrennt werden, sodass derselbe Konstruktionsprozess immer wieder verwendet werden kann. Der Typ der redCarConfig-Variable ist dabei Car.() -> Unit. Diese Schreibweise definiert, dass es sich nicht um eine normale Funktion, sondern eine Extension Function für Car handelt, die Unit zurückliefert. Man könnte apply also als eine "Higher-Order Extension-Function" bezeichnen, da sie selbst eine Extension Function ist, die als Parameter eine andere Extension Function erwartet. Ohne diese Konstruktion wäre es nicht möglich, innerhalb des apply-Blocks auf den Receiver als this zuzugreifen.

Fazit

Ich hoffe, dass dieser Artikel einen guten Eindruck darüber gibt, warum es sich "lohnt", Kotlin in einem Projekt einzusetzen, oder zumindest einmal auszuprobieren. Viele Ideen in Kotlin finden sich auch in anderen Programmiersprachen. Properties, Extensions, Nullsicherheit sind nichts neues und für manche Sprachen sogar ein alter Hut. Aber Kotlin bringt diese und viele andere elegante und nützliche Sprachfeatures auf eine bisher unerreicht einfache und zugängliche Art auf die JVM. Ob Kotlin nun das bessere Java ist, muss jeder für sich selbst entscheiden. Viele Leute berichten allerdings darüber, dass sie dank Kotlin wieder mehr Spaß am Programmieren haben.

Jedoch beschränkt sich Kotlin nicht auf die JVM, sondern transpiliert ebenso zu JavaScript oder WebAssembly und kann mittlerweile auch als Kotlin/Native für native Programmierung (z. B. unter iOS) ausprobiert werden.

Kotlin ist vielleicht kein Material für eine PhD-Thesis, aber Kotlin ist definitiv eine moderne Sprache "for a job".

Quelle
  1. James Gosling: The Feel of Java

Autor

Lovis Möller

Lovis Möller ist ein Kotlin early Adopter und Gründer der Kotlin User Group Hamburg. Immer auf der Suche nach neuen Möglichkeiten, Denkweisen und Perspektiven in der Softwareentwicklung.
>> Weiterlesen
Das könnte Sie auch interessieren
botMessage_toctoc_comments_9210