Stand: 2024-09-16
Java vs. Kotlin? Eine Typfrage!
Typen sind in Programmiersprachen wichtig für korrekten Code. Warum Kotlin hier deutlich besser als Java ist.
Inhalt
Typsysteme legen die Möglichkeiten fest, mit denen Programmierer zulässige Werte in Form von Typen beschreiben können. Sie haben damit großen Einfluss auf die Korrektheit der Implementierung und auf das Schreiben des Codes. Die Typsysteme von Java und Kotlin haben einiges gemeinsam, schließlich hat Jetbrains Kotlin entworfen, um seinen Java-Entwicklern schrittweise den Wechsel zu einer produktiveren Sprache zu ermöglichen.
Kompatibilität zu Java war und ist deshalb eine wichtige Rahmenbedingung – das gilt natürlich auch für das Typsystem. Allerdings bietet das Typsystem von Kotlin einige große Verbesserungen gegenüber Java – unter anderem für den bekannten Milliarden-Dollar-Fehler. Auch funktionale Programmierung wird in Kotlin deutlich einfacher.
Gemeinsamkeiten
Sowohl Java als auch Kotlin sind statisch typisiert, was bedeutet, dass Typen zur Übersetzungszeit vom Compiler überprüft werden. Das Gegenteil davon sind dynamisch typisierte Sprachen wie Python oder Ruby, bei denen Typen erst zur Laufzeit ermittelt oder auch erzeugt werden.
Beide Sprachen setzen auf nominale Typisierung, bei der Typen explizit mit einem Namen deklariert werden. Zwei Variablen haben kompatible Typen, wenn der deklarierte Typename gleich ist. Subtypen sind in beiden Sprachen möglich.
Der Circle
im folgenden Beispiel ist nur deshalb auch vom Typ Shape
, weil Circle
genau diesen Typ explizit deklariert.
interface Shape {
double area();
}
public class Circle implements Shape {
...
@Override public double area() {
return PI * radius * radius;
}
}
Würde die einzige Methode area
aus der Schnittstelle Shape
ohne diese Angabe implementiert, wäre die Struktur der Schnittstelle zwar erfüllt, aber ohne den Namen des Typs ist das in Java und Kotlin nichts wert. Das ist genau der Unterschied zu einem strukturellen Typsystem, wie es zum Beispiel in Go verwendet wird.
Dort gilt eine Schnittstelle als implementiert, wenn ein Objekt der Struktur der Schnittstelle entspricht. Das jeweilige Objekt muss nicht einmal etwas von der Existenz der Schnittstelle wissen. Nominale Typisierung wie in Java oder Kotlin ist weniger flexibel, bietet dafür aber mehr Sicherheit, weil keine versehentliche Äquivalenz von Typen auftreten kann.
Außerdem unterstützen beide Sprachen parametrischen Polymorphismus, besser bekannt als Generics. Genau in diesem Punkt gibt es auch ein paar Unterschiede, die später noch genauer beleuchtet werden.
Der Milliarden-Dollar-Fehler
Referenztypen können in Java außer dem deklarierten Typ immer auch den Wert null
annehmen. Da man aus dem angegebenen Typ nicht darauf schließen kann, ob eine Referenz null
ist oder nicht, müsste man diesen Fall an allen möglichen Stellen explizit berücksichtigen – genau das wird aber oft vergessen.
Das führt dann zu einem der häufigsten Fehler, der NullPointerException
. Im Laufe der Zeit hat null
so viel Schaden angerichtet, dass sein Erfinder, der britische Informatiker Tony Hoare, seine Erfindung als “Billion-Dollar Mistake” bezeichnet.
Immerhin ist Besserung in Java zumindest angedacht, denn in Zukunft soll null
bei deklarierten Typen explizit ausgeschlossen werden können. Geplant ist, mit einem Ausrufezeichen hinter dem Typ null auszuschließen, wie zum Beispiel String!
. Allerdings ist das bisher nur ein Entwurf (Java Enhancement Proposal, kurz JEP), der noch nicht einmal eine Nummer hat. Zudem sind der Aufwand und die Dauer mit XL gekennzeichnet, was bedeutet, dass noch einige Jahre ins Land gehen werden, bevor dieser Ansatz helfen wird, NullPointerException
s auszumerzen.
Der größte Nachteil ist allerdings, dass alles, was null
nicht explizit ausschließt, weiterhin null
sein kann – und das dürfte bei all dem existierenden Java-Code sehr viel sein.
Kotlin ist genau in die entgegengesetzte Richtung gegangen und trennt nullable- und non-nullable-Typen sicherheitshalber in zwei Typuniversen. Nur mit einem Fragezeichen markierte Typen lassen den Wert null zu. Das sieht dann zum Beispiel so aus: String?
. Bei Typen ohne Fragezeichen schließt der Compiler aus, dass Parameter, Variablen oder Felder jemals den Wert null annehmen.
Für den sicheren Umgang mit nullable-Typen bringt Kotlin einige praktische Werkzeuge mit. Besonders häufig ist der Safe Call Operator zur sicheren Dereferenzierung. Dem üblichen Punkt zum Zugriff auf Methoden und Properties wird dabei ein Fragezeichen vorangestellt: ?.
. Mit diesem Operator kann man sich auf sichere Weise durch eine verschachtelte Struktur hangeln, bei der Elemente null sein können:
val result: String? = a?.b?.c
Wie an der expliziten Typangabe zu sehen ist, schließt der Ergebnistyp dann konsequenterweise auch null
ein. Denn wenn ein Element in der Kette null ist, kann am Ende nichts anderes als null
herauskommen.
Für den Fall, dass eine Referenz den Wert null
hat, kann eine Alternative angegeben werden. Genau das ist der Zweck des Elvis-Operators, der nach der Haartolle des berühmten Sängers benannt ist. Denn dieser Operator besteht aus einem Fragezeichen und einem Doppelpunkt, was man auch als Elvis-Emoji lesen kann: ?:
. Praktisch sieht das so aus:
val result: String = a?.b?.c ?: "default"
Elvis lebt!
Wer sich sicher ist, es besser als der Compiler zu wissen, oder keine Angst vor einer NullPointerException
hat, kann den Not-Null-Assertion-Operator !!
verwenden. Wenn die damit versehene Referenz doch null
ist, wird eine Exception geworfen.
Hat a im folgenden Beispiel den Wert null
, führt das zu einer NullPointerException
:
a!!.b()
Am besten bleibt dieser Ansatz die Ausnahme, aber zum Beispiel in Tests ist er durchaus praktisch.
Interessant sind in diesem Zusammenhang Smart Casts, bei denen der Compiler den Typ aus dem Kontrollfluss ableiten kann. Ein einfaches Beispiel ist eine null-Prüfung mit if
:
import kotlin.random.Random.Default.nextBoolean
fun stringOrNull(): String? = if (nextBoolean()) "exists" else null
val possiblyNull = stringOrNull()
if (possiblyNull != null) {
println(possiblyNull.length)
}
Der Compiler findet hier heraus, dass possiblyNull
nicht mehr null
sein kann, wenn die Bedingung erfüllt ist, und wandelt dementsprechend den Typ um. Deshalb darf mit dem üblichen Punkt (statt ?.
) dereferenziert werden.
Schließlich bietet Kotlin noch eine ganze Reihe von Collection-Funktionen zum sicheren Umgang mit null
, zum Beispiel filterNotNull.
val stringsOrNull: List<String?> = listOf("a", null, "c")
val strings: List<String> = stringsOrNull.filterNotNull()
Hier werden nicht nur enthaltenen Null-Werte herausgefiltert, sondern auch ein non-nullable Typ zurückgegeben.
Das Problem mit den Ausnahmen
Eine der umstrittensten Entwurfsentscheidungen von Java sind Checked Exceptions. Dabei handelt es sich um Exceptions, die von einer aufrufenden Methode behandelt oder deklariert werden müssen – sie zu ignorieren ist nicht möglich. Die Intention ist löblich, denn so können Exceptions nicht versehentlich oder aus Trägheit ignoriert werden.
Allerdings sieht die praktische Nutzung weniger glanzvoll aus, denn oft kann man auf einer tiefen Ebene im Code die Exceptions nicht sinnvoll behandeln, sondern muss sie wie eine Luftblase im Wasser aufsteigen lassen. Dabei hat man zwei Möglichkeiten: Man kann die Exception bei allen Methoden der Aufrufkette deklarieren oder man kann sie in eine neue Exception verpacken.
Der erste Ansatz hat den Nachteil, dass alle Methoden der Aufrufkette von dem durch den Exception-Typ ausgedrückten technischen Detail abhängig sind. Dadurch wird der Code schwer änderbar und die Abstraktion wird teilweise zerstört. Die folgende Schnittstelle zur Nutzerverwaltung deklariert zunächst keine Exceptions:
interface UserStore {
void add(User user);
Optional<User> findByName(String name);
void delete(User user);
}
Nehmen wir an, es gäbe eine Implementierung auf Basis einer SQL-Datenbank, dann müssten die Methoden, die die Schnittstelle implementieren, mindestens eine SQLException
deklarieren (wie gesagt, wenn sie nicht lokal behandelt wird):
class SqlUserStore implements UserStore {
@Override
public void add(User user) throws SQLException {
// Datenbanklogik ...
}
// weitere Methoden-Methoden ...
}
Da nun die Aufrufer dieser Schnittstelle gezwungen sind, mit SQLException
s umzugehen, werden nicht nur sie davon abhängig, sondern auch in der Schnittstelle muss diese Exception deklariert werden.
interface UserStore {
void add(User user) throws SQLException;
// ...
}
Das ist also keine gute Lösung. Ganz sicher war es auch nicht die Lösung, die die Designer der Sprache im Sinn hatten.
Wie sähe die zweite Variante mit dem Verpacken der ursprünglichen Exception in eine Exception auf dem Abstraktionsniveau der Schnittstelle aus? Zu diesem Zweck könnte man eine UserCouldNotBeAddedException
kreieren. Sofern es sich dabei wiederum um eine Checked Exception handelt, wiederholt sich das Problem auf der nächsthöheren Ebene und so weiter.
Das heißt, dass man gezwungen ist, jede Menge Exception-Klassen zu definieren, um die Fehler sauber von Ebene zu Ebene zu transportieren. Trotzdem wäre das sehr sauberes API-Design.
Die Praxis sieht allerdings oft anders aus, denn da möchten oder müssen Entwickler eine Funktionalität in knapper Zeit fertigbekommen und wollen sich nicht auch noch mit zig Exceptions herumschlagen. Also werden sie lokal mit einer Pseudo-Behandlung verarbeitet oder in eine RuntimeException
verpackt, womit die ganze Idee von Checked Exceptions ad absurdum geführt wird.
Aber der wohl größte praktische Nachteil von Checked Exceptions ist, dass dieser Ansatz denkbar schlecht zu funktionaler Programmierung passt. Wenn einer Funktion wie filter
ein Lambda-Ausdruck übergeben wird, der eine Checked Exception werfen könnte, muss diese Exception an Ort und Stelle behandelt werden, weil die zugehörige funktionale Schnittstelle java.util.function.Predicate
keine Exceptions deklariert.
Nehmen wir als Beispiel diese Methode, die eine Checked Exception werfen kann:
public static boolean unsafeCondition(int x) throws Exception {
if (x < 23) {
return true;
}
throw new Exception("Nö!");
}
Wird diese Funktion innerhalb eines Lambda-Ausdrucks aufgerufen, gibt es ohne weitere Behandlung einen Compiler-Fehler:
// Compiler-Fehler: Unhandled exception
Stream.of(1, 2, 3).filter(x -> unsafeCondition(x));
So wird aus dem ehemals schönen Code diese Scheußlichkeit:
Stream.of(1, 2, 3).filter(x -> {
try {
return unsafeCondition(x);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
Kotlin ist in diesem Punkt deutlich pragmatischer und besser für funktionale Programmierung geeignet, denn da gibt es schlicht keine Checked Exceptions. Dementsprechend ist auch in einem Lambda-Ausdruck der Aufruf einer Funktion, die Exceptions wirft, kein Problem:
sequenceOf(1, 2, 3).filter { x -> unsafeCondition(x) }
Interessanterweise steht Java mit seinem Konzept der Checked Exceptions nach wie vor allein da, denn keine neuere Sprache hat diesen Ansatz übernommen.
Spitzentyp
In Javas Typsystem gibt es zwei Säulen: Referenztypen (Objekte) und primitive Typen wie int
oder boolean
. Aber es gibt kein Dach darüber, das beide Säulen vereinen würde.
Wenn etwa eine Liste von ganzen Zahlen benötigt wird, kann man nicht List<int>
schreiben, weil generische Klassen als Typparameter nur Referenztypen akzeptieren. Stattdessen muss man an dieser Stelle explizit den entsprechenden Referenztyp angeben, in diesem Fall Integer
. Immerhin kümmert sich der Compiler dank Auto Boxing an den meisten Stellen um die Konvertierung, so dass Entwicklern wenigstens Integer.valueOf(x)
erspart bleibt.
Technisch findet in Kotlin genau das Gleiche statt, denn natürlich gelten auch für Kotlin auf der JVM die gleichen Regeln wie für Java. Allerdings vereint Kotlin primitive Datentypen und Referenztypen in seinem Typsystem unterhalb des Typs Any
. Demnach gibt es auch bei generischen Typen keinen Unterschied zwischen einem primitiven Integer und einem als Objekt verpackten Integer
.
Der gleiche Typ Int
wird für die Definition primitiver Werte und als Typparameter für eine Liste verwendet:
val simple: Int = 3
val intList: List<Int> = listOf(1, 2, 3)
val anyList: List<Any> = intList
Außerdem zeigt das Beispiel, dass List<Int>
ein Subtyp von List<Any>
ist. In der ersten Zeile verwendet der Compiler automatisch einen primitiven Integer, entsprechend Javas int
. Genau wie in Java werden in der zweiten Zeile die Werte automatisch in ein Objekt verpackt, wobei sich der Compiler – und nicht der Entwickler – um diese technische Unterscheidung kümmert.
Ein angenehmer Nebenaspekt ist, dass in Kotlin auf primitiven Typen Erweiterungsfunktionen definiert werden können. Im folgenden Beispiel wird eine Funktion namens times
auf Int
definiert, die die übergebene Aktion so oft wiederholt, wie es die Zahl besagt.
fun Int.times(action: (Int) -> Unit) {
(1..this).forEach { value -> action(value) }
}
3.times { println("Hello, World!") }
Der Typ, der am Boden liegt
Das Gegenstück zu Any
ist in Kotlin der sogenannte Bottom Type Nothing
. Dieser Typ ist der Subtyp aller Typen.
Eine Funktion, die den Rückgabetyp Nothing
hat, kann nie einen Wert zurückgeben. Typescript-Programmierer kennen diesen Typ unter dem einleuchtenden Namen Never
. Dementsprechend gibt es auch kein Objekt dieses Typs, denn der Fall, dass man so ein Objekt bräuchte, kann ja nie eintreten.
Wozu soll dieser Typ dann überhaupt gut sein? Damit lässt sich ausdrücken, dass eine Methode niemals einen normalen Wert zurückgeben wird und auch nicht regulär endet. Ein praktisches Beispiel ist die TODO
-Funktion aus Kotlins Standardbibliothek:
fun TODO(): Nothing = throw NotImplementedError()
Da Nothing
der Subtyp aller Typen ist, kann diese Funktion anstelle einer richtigen Implementierung angegeben werden:
fun square(x: Int): Int = TODO()
Eigentlich sollte die Funktion square
einen Wert vom Typ int
zurückgeben, aber TODO
erfüllt diese Bedingung auch, weil Nothing
eben auch ein Subtyp von Int
ist.
Mit Nothing
könnte man auch in einer Baumstruktur für Blattknoten ausdrücken, dass sie keine Kinder haben können:
data class Node(val name: String, children: List<Node>)
val leaf = Node("leaf", emptyList<Nothing>())
Ansonsten findet Nothing
auch bei Typprojektionen (siehe unten) Verwendung.
Java hat keinen Bottom Type. Am nächsten heran kommt null
, denn das kann zumindest anstelle eines Referenztypen zurückgegeben werden.
Unit und void
Wenn eine Methode keinen Wert von Interesse zurückgibt, hat sie in Java den Rückgabetyp void
:
public void add(Item item) { ... }
Das Pendant in Kotlin heißt Unit
, was ein Typ mit einem einzigen Wert, nämlich dem Singleton Unit
ist, das ansonsten keine Informationen enthält. Die vorige Java-Methode entspricht in Kotlin dieser:
fun add(item: Item): Unit { ... }
Unit
wird als Rückgabetyp von Funktionen üblicherweise weggelassen, wobei der Compiler diesen Typ dann selbst einsetzt.
Der größte praktische Unterschied zu Javas void
tritt bei Verwendung von generischen Typen auf. Falls kein relevanter Wert zurückgegeben wird, handelt es sich nämlich nicht wie bei void
um einen Sonderfall, so dass dieser Typ auch wie jeder andere als Typparameter verwendet werden kann.
So ließen sich zum Beispiel Funktionen, die Ereignisse behandeln, aber nichts weiter zurückgeben, in einer Liste vom Typ (Int) -> Unit
speichern (Eingabe Int
, kein Rückgabewert).
val handlers: MutableList<(Int) -> Unit> = mutableListOf()
handlers += { x -> println(x) }
Generics
Generische Typen, also Typen wie List<String>
, sehen nur auf den ersten Blick in beiden Sprachen gleich aus, denn in Java sind sie invariant, was bedeutet, dass eine List<String>
kein Subtyp von List<Object>
ist. Bei Arrays, wo das nicht der Fall ist, würde der Compiler folgenden problematischen Code akzeptieren:
String[] strings = new String[3];
Object[] objects = strings;
objects[0] = 1;
Der Compiler akzeptiert das, weil String[]
ein Subtyp von Object[]
ist. Zur Laufzeit würde das zu einer ArrayStoreException
führen, da der Wert 1 kein String ist. Um genau dieses Problem zu verhindern, sind generische Typen in Java invariant, also ohne Vererbungsbeziehung zueinander.
Für die folgenden Beispiele dient diese kleine Klassenhierarchie und die Schnittstelle Box
als Grundlage:
class Animal {}
class Dog extends Animal {}
interface Box<T> {
void put(T item);
T fetch();
void replaceWith(Box<T> other);
}
Dog
ist also eine Unterklasse von Animal
, während Box
einen Wert eines beliebigen Typs T
aufnehmen kann.
Dass generische Typen in Java tatsächlich invariant sind, zeigt dieses Beispiel:
void demo(Box<Dog> animal) {
Box<Animal> dog = animal; // Compiler-Fehler
}
Der Compiler beschwert sich mit dem Fehler: “Inkompatible Typen: BoxBox<Dog>
ist eben kein Untertyp von Box<Animal>
.
Um trotzdem die gewünschte Flexibilität zu erreichen, gibt es in Java sogenannte Use-site Variance mit Wildcard Types. Use-site bedeutet, dass die Angaben dort erfolgen, wo der Typ verwendet wird – im Gegensatz zur Angabe in der generischen Klasse selbst. Das ist ein wichtiger Unterschied zu Kotlin, doch dazu kommen wir noch.
Der obige Compiler-Fehler lässt sich mit diesem Wildcard-Typ beheben:
void demo(Box<Dog> dog) {
Box<? extends Animal> animal = dog;
}
Durch ? extends Animal
wird ausgedrückt, dass an dieser Stelle der Typ Animal
oder jeder davon abgeleitete Typ (siehe oben: class Dog extends Animal
) akzeptiert wird. Der Typparameter der Box
ist nun kovariant.
So weit so gut, doch de facto wird animals dadurch zu einer Collection, die nur gelesen werden kann; denn der Box animal kann kein Dog-Objekt hinzugefügt werden. Das folgende Beispiel kann nicht kompiliert werden:
void demo(Box<Dog> dog) {
Box<? extends Animal> animal = dog;
animal.put(new Dog()); // Compiler-Fehler! Kovarianter Typ an Eingabeposition
}
Das liegt daran, dass Methoden eines Subtyps mindestens die Parametertypen des Obertyps akzeptieren müssen (aber mehr akzeptieren können). Bei Rückgabewerten ist es genau andersherum, denn da dürfen nur Werte zurückgeben werden, die den Rückgabetypen im Obertyp entsprechen oder eine Einschränkung davon sind.
Zum Beispiel ist Dog
eine Einschränkung des Typs Animal
– Aufrufer, die mit einem Rückgabewert vom Typ Animal
zurechtkommen, haben auch mit dem spezielleren Dog
kein Problem. Doch durch die obige Deklaration mit ? extends Animal
darf dieser Typparameter nur noch in Ausgabepositionen, also für Rückgabewerte verwendet werden.
In Kotlin gilt das gleiche Prinzip, aber es wird klarer ausgedrückt, denn dort kann bei der Definition von Klassen angegeben werden, ob ein Parameter in einer Eingabe- oder Ausgabeposition vorkommen soll und demnach eben kontravariant oder kovariant ist.
interface Box<out T> {
fun put(value: T) // Compiler-Fehler
fun fetch(): T
fun replaceWith(other: Box<T>) // Compiler-Fehler
}
Genau hier weist der Compiler schon bei der Erstellung der Klasse auf den Fehler hin, denn durch das Schlüsselwort out
darf dieser Typ nur noch an Ausgabepositionen vorkommen. Bei den Methoden put
und replaceWith
kommt er aber an Eingabepositionen vor. Der Typ T
ist somit kovariant.
Wenn ein Typparameter an beiden Positionen vorkommt, muss er invariant sein. Wenn der Typparameter allerdings invariant ist, dann lässt sich die folgende Methode, bei der ein Hund in Box für Tiere im allgemeinen umgesiedelt werden soll, nicht implementieren:
fun move(source: Box<Dog>, target: Box<Animal>) {
target.replaceWith(source) // Compiler-Fehler
}
Hier beklagt sich der Compiler, dass source
vom Typ Dog
ist, obwohl die Methode target.replaceWith
ein Objekt vom Typ Animal
erwartet.
Lösen lässt sich das Problem mit einer Typprojektion, was der Use-site Variance von Java ähnelt:
interface Box<T> {
fun put(value: T)
fun fetch(): T
fun replaceWith(other: Box<out T>) // Typprojektion
}
Man verspricht dem Compiler auf diese Weise, die an replaceWith
übergebene Box
-Instanz für den Typ T
nur lesend zu verwenden. Dadurch wird vom Compiler im Hintergrund ein neuer Typ erstellt:
interface Box<out T> {
fun put(value: Nothing)
fun fetch(): T
fun replaceWith(other: Box<out T>)
}
Die einzige Stelle, an der T
als Typ eines Eingabeparameters vorgekommen ist, wird durch Nothing
ersetzt, wodurch es unmöglich wird, dort einen Wert zu übergeben. Die move
-Funktion wird nun vom Compiler akzeptiert.
Das Java-Pendant ist prinzipiell gleich:
interface Box<T> {
void put(T item);
T fetch();
void replaceWith(Box<? extends T> other); // Wildcard
}
So akzeptiert der Compiler auch diese Methode:
void move(Box<Dog> source, Box<Animal> target) {
target.replaceWith(source);
}
Java hat erst nachträglich mit Version 1.5 Generics bekommen, weshalb es heutzutage nach wie vor möglich ist, generische Typen wie List<T>
auch ganz ohne Typparameter zu nutzen.
ArrayList raw = new ArrayList();
raw.add("string");
raw.add(1);
Damit wird natürlich jegliche Typsicherheit ausgehebelt, die der Compiler bieten könnte. Zum Glück sieht man solchen Code in der Praxis selten, denn Entwicklungsumgebungen machen schon beim Schreiben auf diese Schwachstelle aufmerksam. In Kotlin müssen Typparameter dagegen immer angegeben werden, so dass dieses Problem prinzipiell ausgeschlossen ist.
Die praktischen Möglichkeiten sind in beiden Sprachen recht ähnlich, die Umsetzung unterscheidet sich dagegen deutlich. Insbesondere die Schlüsselwörter in und out sind deutlich intuitiver und einfacher als Javas Ansatz.
Flow-sensitive Typing
Wenn der Typ eines Ausdrucks von seiner Position im Kontrollfluss abhängt, nennt sich das Flow-sensitive Typing. Der Compiler kann durch Analyse des Kontrollflusses den ursprünglichen Typ weiter einschränken.
Im nächsten Kotlin-Beispiel wird geprüft, ob es sich bei dem Parameter something um einen String handelt. Wenn diese Bedingung erfüllt ist, kann der Compiler den Typ auf String
einschränken, weshalb ohne explizite Typumwandlung die Eigenschaft length des String-Objekts aufgerufen werden kann.
fun demo(something: Any) {
if (something is String) {
println("Length: ${something.length}")
}
}
Dieser spezielle Fall von Flow-sensitive Typing wird in Kotlin als Smart Cast bezeichnet.
Seit Java 17 ist auf den ersten Blick Ähnliches mit instanceof
möglich:
public static void demo(Object something) {
if(something instanceof String s) {
System.out.printf("Length: %d%n", s.length());
}
}
Dabei handelt es sich allerdings um Pattern Matching for instanceof
und nicht um Flow-sensitive Typing.
Diesen Unterschied veranschaulicht das folgende Beispiel in Kotlin:
fun demo(something: Any) {
if (something !is String) {
return
}
println(something.uppercase()) // String
}
Der Compiler analysiert den Kontrollfluss, so dass er weiß, dass nach der Bedingung der Typ von something nur String sein kann. In Java ist der Typ dagegen auch nach der Bedingung noch Object
, weshalb folgender Code nicht kompiliert werden kann:
public static void demo(Object something) {
if(!(something instanceof String)) {
return;
}
System.out.println(something.toUpperCase()); // Compiler-Fehler
}
Zahlen ohne Vorzeichen
Zahlen vom Typ int
verwenden in Java immer das erste Bit zur Darstellung des Vorzeichens. In Kotlin gibt es zusätzlich noch vorzeichenlose Ganzzahlen: UByte
, UShort
, UInt
und Ulong
.
Durch den Verzicht auf das Vorzeichen verdoppelt sich der größtmögliche darstellbare Wert. Außerdem kann es sinnvoll sein, negative Werte aus semantischen Gründen auszuschließen. Ein negatives Alter einer Person dürfte zum Beispiel nur selten erwünscht sein.
Konvertierung von Zahlen
Zahlen werden in Java automatisch in den nächstgrößeren Typ konvertiert, wenn das nötig ist.
byte a = 1;
int b = a;
In Kotlin ist dagegen eine explizite Konvertierung nötig, um den Segen des Compilers zu bekommen:
val a: Byte = 1
val b: Int = a.toInt()
Der Grund dafür ist, dass kleine Zahlentypen keine Subtypen von größeren Typen sind. Dieses strikte Verhalten soll unangenehme Überraschungen verhindern.
Fazit
Sowohl Java als auch Kotlin verfügen über solide Typsysteme, mit denen sich gut große Anwendungen entwickeln lassen. Kotlin bringt zusätzlich Verbesserungen wie die Prüfung auf null
, Smart Casts und einen klareren Ansatz für Generics mit.
Durch Any
, Nothing
und Unit
entfallen in Kotlin einige Sonderfälle zum Beispiel im Zusammenspiel mit generischen Typen. Der Verzicht auf Checked Exceptions macht vor allem funktionale Programmierung in Kotlin einfacher.