Wer ein neues Projekt auf der Java Virtual Machine (JVM) aufsetzt, steht vor einer grundlegenden Entscheidung: Java oder Kotlin? Was, wenn eine Sprache gebraucht wird, die eine ganze Klasse von Bugs unmöglich macht, Fehler zur Kompilierungszeit statt zur Laufzeit findet und vor Sicherheitsproblemen schützt? Kurz: Was, wenn es eine sichere Sprache sein muss?

In Teil 1 dieser Serie wurden die Ökosysteme beider Sprachen verglichen; Teil 2 betrachtete die Multiplattform-Fähigkeiten; Teil 3 lieferte eine allgemeine Einführung in die Kotlin-Syntax und ihre Interoperabilität mit der Java-Plattform. In diesem Teil geht es darum, wie sicher die beiden Sprachen sind.

Als Beispieldomäne für diesen Teil 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.

Was ist mit Sicherheit gemeint?

Mit „sicher“ ist üblicherweise Folgendes gemeint:

  • Speichersicherheit (memory safety): Schutz vor speicherbezogenen Fehlern wie Buffer Overflows oder Dangling Pointers
  • Thread-Sicherheit (thread safety): Schutz vor Race Conditions und Datenkorruption, wenn mehrere Threads gleichzeitig auf dieselbe Funktion zugreifen
  • Nullsicherheit (null safety): Schutz davor, Null-Referenzen zu dereferenzieren
  • Injection-Sicherheit: klare Grenzen zwischen Code und Benutzereingaben
  • Typsicherheit: Operationen sind auf Werte kompatibler Typen beschränkt

Das sind natürlich nicht die einzigen Aspekte, unter denen sich Sicherheit betrachten lässt. Das OWASP-Projekt zählt beispielsweise die zehn kritischsten Web-Sicherheitsrisiken auf, an erster Stelle „Broken Access Control“. Solche Risiken lassen sich aber nicht über Sprachdesign adressieren.

Aufgelistet ist hier das, was eine Programmiersprache dazu beitragen kann, sichereren Code zu schreiben. Die Punkte werden in der genannten Reihenfolge durchgegangen, und dabei wird unterschieden, wo Sprache oder JVM eine Garantie geben, wo es Prüfungen gibt und wo es nur um Ergonomie geht. Wer mit der Kotlin-Syntax nicht vertraut ist, sollte vorher Teil 3 lesen.

Speichersicherheit

Speichersicherheit ist in Java und Kotlin identisch, weil der Speicher in beiden Fällen von der JVM verwaltet wird. Man muss nicht daran denken, zugewiesenen Speicher wieder freizugeben, denn die JVM gibt ihn frei, sobald er nicht mehr gebraucht wird. Speicherlecks können trotzdem entstehen, etwa wenn der eigene Code ungewollt Referenzen festhält, aber im Großen und Ganzen erledigt die JVM das. Für Thread-Sicherheit ist dagegen deutlich mehr zu beachten.

Thread-Sicherheit

In Java und Kotlin wird der Speicher automatisch verwaltet, um Thread-Sicherheit muss man sich dagegen selbst kümmern. Man muss zum Beispiel daran denken, ScopedValue zu verwenden (eine moderne Alternative zu ThreadLocal, seit JDK 25 — September 2025), um nicht neu zuweisbare Kontextdaten mit einem bestimmten Thread und dessen Kind-Threads zu teilen. Man muss daran denken, AtomicBoolean oder ConcurrentHashMap zu nutzen, um Daten sicher zwischen Threads zu teilen. Man muss daran denken, ReentrantLock, das Schlüsselwort synchronized in Java oder die Funktion synchronized() in Kotlin einzusetzen, um wechselseitigen Ausschluss für bestimmte Code-Blöcke zu garantieren. Es gibt viel zu beachten und wenig Unterstützung.

Java und Kotlin unterscheiden sich durchaus deutlich in ihren Ansätzen zu Nebenläufigkeit und I/O-Durchsatz, allen voran bei Virtual Threads und Coroutinen. Diese Features betreffen aber Nebenläufigkeitsmodelle und Durchsatz, nicht Garantien für Thread-Sicherheit. Deshalb bleiben sie hier außen vor.

Insgesamt ist die Lage bei der Thread-Sicherheit in Java und Kotlin gleich: Sie ist nicht garantiert. Bei der Nullsicherheit sieht es besser aus, denn dort kann der Compiler tatsächlich helfen.

Nullsicherheit

In Java kann derzeit jedes Objekt zwei Zustände haben, initialisiert oder null (JVM-Besonderheiten wie den Larval State einmal ausgenommen). Das Typsystem signalisiert das nicht, und der Compiler prüft es nicht. Project Valhalla könnte das mit „Null-Restricted and Nullable Types“ irgendwann ändern, aber wann dieses Feature kommt, steht in den Sternen. Bis dahin warten viele NullPointer darauf, geworfen zu werden. Ein Beispiel ist der folgende Repository-Code:

class JRentalRepository {  
    private Map<JRentalId, JRental> allRentals;  
    // …  
    JRental getOne(JRentalId id){ // <2>
        return allRentals.get(id); // <1> 
    }  
}
// somewhere else
JMoney calculatePrice(JRental rental){
    if(rental.rentedDays().rawValue() > 5){ // <3>
        /* business code here */
    }
    // etc.
}\

Maps geben null zurück, wenn der Schlüssel nicht gefunden wird. Weder beim Zugriff <1> noch in der Methodensignatur <2> wird deutlich, dass die Referenz null sein könnte. Diese Referenz wird dann weitergereicht, bis irgendwann beim Zugriff ein NullPointer geworfen wird <3>, weit entfernt von der Stelle, die ihn verursacht hat.

JSpecify-Annotationen und das Compiler-Plugin NullAway (das diese Annotationen prüft) erlauben es, null-Prüfungen zur Kompilierungszeit in Java nachzurüsten (ausführlicher dazu: Wie man den Milliarden-Dollar-Fehler repariert). Das ist sehr hilfreich, weshalb Spring Boot 4 und Spring Framework 7 dieses Setup übernommen haben. Man kann festlegen, dass „nicht null“ der Standard ist, und dann nur noch das mit @Nullable markieren, was null werden kann. Das Compiler-Plugin prüft, ob diese Annahmen stimmen.

class JRentalRepository {  
    private Map<JRentalId, JRental> allRentals;  
    // …  
    @Nullable JRental getOne(JRentalId id){ // <2>
        return allRentals.get(id); // <1> 
    }  
}
// somewhere else
JMoney calculatePrice(@Nullable JRental rental){ // <3>
    if(rental.rentedDays().rawValue() > 5){ 
       //    ^
       // <4> Error: Might be null
       
       /* business code here */
    }
    // etc.
}\

Das Compiler-Plugin weiß, dass Maps null zurückgeben können <1>, verfolgt den Datenfluss und erzwingt @Nullable-Annotationen <2> <3> überall dort, wo der Wert weitergereicht wird. Es bricht die Kompilierung außerdem ab, wenn auf ein nullbares Objekt ohne Null-Prüfung zugegriffen wird <4>.

Zu beachten ist, dass NullAway dokumentierte Fälle bewusster Unsoundness kennt, zugunsten von Einfachheit, Geschwindigkeit und geringerem Annotationsaufwand. Einer davon: Methodenaufrufe ohne Argumente (etwa Getter) werden als seiteneffektfrei und deterministisch angenommen, liefern also bei jedem Aufruf denselben Wert. Für veränderliche Objekte, die von einem anderen Thread modifiziert werden, muss das nicht stimmen. Auch mit NullAway kann also eine NullPointerException auftreten, die Wahrscheinlichkeit sinkt aber deutlich.

Auch Kotlin dokumentiert die Fälle, in denen eine NullPointerException auftreten kann. Ein wesentlicher Grund ist ein Fehler in der Initialisierungsreihenfolge, ein anderer die Interaktion mit Java-Code ohne Nullability-Garantien. Subjektiv betrachtet hält diese Liste weniger Überraschungen bereit als die von NullAway.

In Kotlin gibt es das null-Prüfverhalten ab Werk, und syntaktisch kompakter:

class KRentalRepository(  
    private val allRentals: Map<KRentalId, KRental>
){  
    fun getOne(id: KRentalId): KRental? { // <2>
        //                            ^
        // won’t compile without explicit nullability marker
        return allRentals.get(id) // <1> 
    }  
}
// somewhere else
fun calculatePrice(rental: KRental?): KMoney { // <3>
    if(rental.rentedDays.rawValue > 5){ 
       //    ^
       // <4> Error: Might be null
       
       /* business code here */
    }
    // etc.
}\

In Kotlin ist „nicht-null“ der Standard, und man muss ein ? hinter den Typ setzen, um anzuzeigen, dass er null sein kann <2> <3>. Kotlin weiß außerdem, welche Funktionen der Standardbibliothek null zurückgeben können (z. B. Map.get <1>). Wie NullAway erzwingt es explizite Nullability-Marker und Prüfungen, denn der Code kompiliert nicht, wenn auf einen nullbaren Wert ohne Prüfung zugegriffen wird <4>. Die Kotlin-IDEs visualisieren das direkt: Alles Nullbare wird rot, wenn ohne Prüfung darauf zugegriffen wird.

Diese Prüfungen funktionieren nicht nur mit der Java-Standardbibliothek, sondern auch mit eigenem Code, der mit JSpecify-Annotationen versehen ist. Seit Kotlin 1.5.20 (Juni 2021) werden Abweichungen als Warnungen gemeldet, seit 2.1.0 (November 2024) als Fehler.

Ein wichtiger Nebeneffekt der null-Prüfung: Nicht-nullbare Felder müssen im Konstruktor initialisiert werden. Das führt zu Problemen, wenn die Felder zum Zeitpunkt der Objekterzeugung noch nicht verfügbar sind, etwa weil sie erst in einer Methode eines Unit-Tests gesetzt werden. Ein anderes Beispiel sind Felder, die von Dependency-Injection-Containern wie Spring erzeugt werden und eine @PostConstruct -Methode benötigen oder stark auf Setter-Injection setzen. In solchen Fällen ist ein Feld faktisch „nicht-null“, wird aber spät initialisiert. Es lässt sich mit dem Schlüsselwort lateinit var markieren. Der Compiler erlaubt dann, dass solche Variablen bei der Objekterzeugung nicht initialisiert sind. Ein Zugriff, ohne dass ein Nicht-Null-Wert gesetzt wurde, löst allerdings sofort eine UninitializedPropertyAccessException aus. Die meisten Frameworks unterstützen heute Constructor Injection, lateinit wird also nicht mehr so oft gebraucht. Es gibt aber weiterhin Fälle, in denen es sehr nützlich ist und in JSpecify schmerzlich fehlt.

Ein weiteres Sprachfeature, das Kotlin mitbringt, sind null-aware Operatoren:

  • der Safe-Call-Operator ?. (z. B. first?.second), der auf das zweite Element nur zugreift, wenn das erste nicht null ist
  • der Elvis-Operator ?: (z. B. first ?: fallback), der einen Fallback liefert, wenn das erste Element null ist

Die verspielte Benennung ergibt Sinn, wenn man sich ?: um 90° nach rechts gedreht vorstellt: Das Fragezeichen wird zur Tolle des Kings. Wer in TypeScript oder C# programmiert, kennt diese Operatoren. Dort sieht der zweite anders aus (?? statt ?:) und heißt Nullish-Coalescing-Operator, das Verhalten ist aber dasselbe.

Zusammen erlauben diese Operatoren, calculatePrice null-sicher umzuschreiben:

fun calculatePrice(rental: KRental?): KMoney { 
    val rentedDays: Int? = rental?.rentedDays?.rawValue // <1>
    if(rentedDays != null && rentedDays > 5){ // <2>
        //        ^          ^^^^
        // if true           then this is also non-null
        
        /* business code here */
    }
    // etc.
}\

Der Safe-Call-Operator gibt Zugriff auf rawValue nur dann, wenn die beiden vorherigen Referenzen nicht null sind. rentedDays ist damit entweder der Rohwert oder null <1>. Danach wird geprüft, ob rentedDays nicht null ist. Trifft das zu, kann der Compiler rentedDays in der if-Bedingung und im if-Rumpf per „Smart Cast“ als nicht-null behandeln <2>.

Mit dem Elvis-Operator wird es noch kürzer:

fun calculatePrice(rental: KRental?): KMoney { 
    val rentedDays: Int = rental?.rentedDays?.rawValue ?: 0 // <1>
    if(rentedDays > 5){
        /* business code here */
    }
    // etc.
}\

rentedDays ist jetzt entweder rawValue oder 0 <1>.

Noch kürzer geht es mit den Kotlin-Extension-Functions takeIf (gibt den Wert nur zurück, wenn er die Bedingung erfüllt) und ?.let (wird nur aufgerufen, wenn alle vorherigen Glieder der Kette nicht null sind). Ob das lesbarer ist als das vorherige Beispiel, darüber lässt sich streiten.

fun calculatePrice(rental: KRental?): KMoney { 
    rental?.rentedDays?.rawValue?.takeIf { days -> days > 5 }?.let {  
        /* only reached if everything before is non-null and days > 5 */
        /* business code here */
    }
    // etc.
}\

In Java gibt es diese Operatoren nicht. Stattdessen lassen sich Null-Prüfungen in einer if-Anweisung verketten, was bei kurzen Ketten vielleicht die lesbarste Variante ist:

JMoney calculatePrice(@Nullable JRental rental){  
    if (rental != null && rental.rentedDays() != null 
        && rental.rentedDays().rawValue() > 5){  
        /* business code here */
    }  
    // etc.
}\

Das Verhalten ließe sich auch mit Optional nachbilden. Ob das idiomatisches Java ist, ist allerdings umstritten:

Optional.ofNullable(rental)
        .map(JRental::rentedDays)
        .map(JDay::rawValue)
        .filter(days -> days > 5)
        .ifPresent(days -> { /* business code here */ });\

Eine Alternative ist Record Pattern Matching (JDK 21 — September 2023), womit sich der folgende Code schreiben lässt. Das Beispiel nutzt zusätzlich das Feature „Unnamed Variables & Patterns“ (JDK 22 — März 2024), um unbenutzte Variablen durch einen Unterstrich _ zu ersetzen, was der Lesbarkeit etwas hilft:

JMoney calculatePrice(@Nullable JRental rental){   
    if(rental instanceof JRental(_, JDay(int rawValue)) 
       && rawValue > 5){  
        /* business code here */
    }  
    // etc.
}\

Das funktioniert und ist wohl idiomatischer als der Einsatz von Optional. Ob es besser ist als verkettete null-Prüfungen, hängt davon ab, wie vertraut man mit Pattern Matching ist, ob sich records einsetzen lassen und ob der fachliche Code lesbarer wird, wenn JRental zerlegt vorliegt.

Wo steht man also? Nullsicherheit kann Java bieten (über die Kombination aus Annotationen und einem Compiler-Plugin eines Drittanbieters, das sie prüft), und Kotlin bietet sie direkt (ab Werk und ergänzt um spezielle Operatoren).

Die Garantien unterscheiden sich allerdings erheblich. In Java bedeutet das Vorhandensein oder Fehlen eines null-Markers nicht, dass dieser Marker zur Kompilierungszeit geprüft wurde. Annotationen und Compiler-Plugin sind schließlich getrennte Werkzeuge. Kotlin-Code kompiliert dagegen nicht, wenn die Nullbarkeit des Typs verletzt wird. Dies führt dazu, dass die Interoperabilität mit Java-Code nicht ganz reibungslos ist. Ein Java-@Nullable String name lässt sich beispielsweise nicht einem Kotlin-val name: String zuweisen. Was aber, wenn der Java-Typ gar keine Annotation trägt? Kotlin behandelt solche Referenzen als Platform Types, deren Nullbarkeit unbekannt ist. IntelliJ zeigt das als String! name an. Der Compiler erlaubt, String! name einem Kotlin-val name: String zuzuweisen. Aber ist das korrekt? Diese Annahme ist nur dann korrekt, wenn die gesamte Java-Codebasis auf Nullness-Verletzungen geprüft ist, ohne eine einzige Ausnahme, und wenn ein nicht annotierter Typ nachweislich „nicht-null“ bedeutet.

Derzeit ist null-Bewusstsein ein Feature des Kotlin-Compilers, während es auf der Java-Seite eine Kombination aus Compiler-Plugin, Annotationen und handgeschriebenen null-Prüfungen ist. Der Kotlin-Compiler fügt null-Prüfungen in Konstruktor und Setter ein, synchron zu den null-Markierungen. In Java muss man die Objects.requireNonNull()-Prüfungen selbst schreiben. Der Grund: null-Prüfungen werden zur Laufzeit nicht von der JVM erzwungen. Es gibt Wege, auf denen Fremdcode den null-Marker ignorieren und ungültige Objekte erzeugen kann. Serialisierungsbibliotheken benötigen etwa oft noch einen parameterlosen Konstruktor, um ein Objekt zu erzeugen, und setzen dann die privaten Felder direkt, an allen Settern vorbei (mehr dazu in Destructoring ist die Zukunft von Javas Encapsulation). Das ist auch für Kotlin-Code ein Problem. Bibliotheken wie Jackson sollten die Annotationen der Felder prüfen und bei der Erzeugung respektieren. Bei Jackson war das nicht immer der Fall, und selbst nachdem Unterstützung ergänzt wurde, ging sie mit erheblichen Performance-Einbußen einher. Diese Einbußen wurden mit Jackson 2.19 (April 2025) behoben, NewStrictNullChecks musste aber weiterhin aktiviert werden, damit null-Markierungen berücksichtigt wurden. Erst seit Jackson 3.0 (Oktober 2025) ist das standardmäßig an.

Kurz gesagt: Die eingesetzte Bibliothek respektiert die Marker womöglich nicht. Immer mehr Bibliotheken unterstützen die JSpecify null-Marker, es ist aber ratsam, kleine Tests zu schreiben, bevor man sich darauf verlässt. Eine Garantie gibt es erst, wenn Nullbarkeit vom Typsystem der JVM erzwungen wird. Das könnte irgendwann mit Null-Restricted and Nullable Types kommen, dieses JDK Enhancement ist aber noch einige Jahre entfernt. Dasselbe gilt für das nächste Thema: sicheres Einbetten von Variablen. Es ist im JDK noch nicht stabil, aber zentral für eine sichere Programmiersprache und einen Blick wert.

Injection-Sicherheit und sicheres Einbetten von Variablen

In Java 21 (September 2023) und Java 22 (März 2024) gab es ein Preview-Feature, mit dem sich Variablen sicher in strukturierte Sprachen wie SQL, HTML, JSON einbetten und Injection-Angriffe umgehen ließen. Diese sogenannten String Templates wurden nie fertiggestellt — das Design war für den praktischen Einsatz zu eingeschränkt — die Auswirkungen auf die Sicherheit sind aber zu wichtig, um sie hier auszulassen.

Angenommen, in Java soll eine SQL-Abfrage gebaut werden, und zwar per String-Konkatenation:

String queryString = “SELECT * FROM Person p WHERE p.name = ‘” + name + “’”;
//                                                              ^^^^^^^^
//                                 UNSAFE! Do not concatenate Sql strings

try (Statement statement = connection.createStatement()) {
    ResultSet resultSet = statement.executeQuery(queryString);
    // …
}\

name so zu konkatenieren, ist eine SQL-Injection mit Ansage. Der Wert von name kann alles sein, und er wird wortwörtlich an queryString angehängt. Ist name gleich 123456' OR 1=1 --, dann lautet der resultierende queryString "SELECT * FROM Person p WHERE p.name = '123456' OR 1=1 --'" und wird genau so zum Statement. Dieses Statement liefert den Angreifern dann sämtliche Nutzer der Datenbank.

Wird stattdessen zuerst die Abfrage erzeugt und werden die Werte danach gesetzt, kann ein bösartiges name die Abfrage selbst nicht mehr verändern. In Java geht das über ein PreparedStatement:

String queryString = “SELECT * FROM Person p WHERE p.name = ?”;
try (PreparedStatement query = connection.prepareStatement(queryString)){
    query.setString(1, name);
    ResultSet resultSet = query.executeQuery();
    // … 
}\

Damit ist das Problem der SQL-Injection gelöst, die Handhabung ist aber etwas unbequem, weil das Definieren der Abfrage und das Einsetzen der Werte zwei getrennte Schritte sind. Dass SQL-Injection-Schwachstellen nach wie vor verbreitet sind, zeigt die Grenzen reiner Entwicklerdisziplin. Injection steht in den OWASP Top 10 von 2025 auf Platz fünf, SQL-Injection allein mit mehr als 14 000 erfassten CVEs (Common Vulnerabilities and Exposures) über alle Webanwendungen hinweg. SQL-Injection ist in vielen Sprachen ein Problem.

Injection-Angriffe beschränken sich allerdings nicht auf SQL. Jeder Code, der vom Benutzer gelieferte Daten in einem String interpretiert, ist gefährdet. Deshalb hat sich das OpenJDK-Team daran gemacht, das sicher zu machen, und in JDK 21 und 22 ein Preview für String Templates ausgeliefert. Die Handhabung ist ausgesprochen ergonomisch:

DB db = new DB(connection); 
try (PreparedStatement query = db.“SELECT * FROM Person p WHERE p.name = \name}") { // <1>
    ResultSet resultSet = query.executeQuery(); 
    // … 
}\

<1> Das Template "SELECT ...", das einen eingebetteten Ausdruck \{name} enthält, wird zusammen mit dem Wert des Ausdrucks (z. B. “Alex”) an den Template-Prozessor DB übergeben. Dieser kann das Template nehmen, ein PreparedStatement erzeugen, alle Werte des Statements setzen und das Ergebnis zurückgeben. All das passiert im Hintergrund, ohne dass man sich um SQL-Injections kümmern muss. Ähnliche Prozessoren ließen sich für JSON oder XML schreiben, um auch deren Erzeugung sicher zu machen.

String Templates würden das Schreiben von SQL/JSON/x sicher und ergonomisch machen, eine großartige Kombination gegen Injection-Angriffe. Das Design wurde aber als nicht gut genug befunden und liegt wieder auf dem Reißbrett, ein neues Release ist nicht in Sicht.

In Kotlin gibt es solche String Templates nicht. Genauer gesagt: Es gibt keine Möglichkeit, einen eigenen Template-Prozessor (z. B. db."") für String Templates zu schreiben. Ein Kotlin-String kann Variablen einbetten, das Ergebnis ist aber immer ein String. Man könnte sagen, der Template-Prozessor ist immer str."". Ein $ im String genügt, und jede Variable, sogar ein Funktionsaufruf, lässt sich in den String interpolieren.

val queryString = “SELECT * FROM Person p WHERE p.name = ‘$name’”
//                                                       ^^^^^
//               UNSAFE! Do not use String interpolation for Sql

val resultSet = statement.executeQuery(queryString)
// …\

So bequem das ist, sicher ist es nur, solange das Endergebnis ein String bleibt. Wird der resultierende String zu einem SQL-Statement oder einem JSON-Dokument geparst, bewegt man sich auf unsicherem Terrain. Custom String Templates wären in Kotlin sehr willkommen, sind aber nicht in Sicht.

Was Java und Kotlin dagegen beide haben, sind typsichere Builder, mit denen sich Objekte sicher konstruieren lassen, statt sich auf String-Parsing zu verlassen.

Typsicherheit — durch Builder

Im vorigen Abschnitt wurde deutlich, wie gefährlich es sein kann, eine SQL-Abfrage aus einem String zusammenzubauen. Die sichere Alternative, gerade bei komplexen Daten, ist ein Builder. Die obige Abfrage lässt sich zum Beispiel mit der SQL-Bibliothek Exposed so ausdrücken:

val results: List<Person> = Persons
    .selectAll() 
    .where { Persons.name eq name } 
    .toList()\

Dieser Builder ist aus zwei Gründen interessant. Er verhindert nicht nur Injection-Angriffe, er führt auch typsicher durch die Abfragesprache. where {} steht etwa erst zur Verfügung, nachdem selectAll() aufgerufen wurde. Soll where {} auch beim einelementigen select() verfügbar sein, lässt sich auch das abbilden. Die Syntax der Abfragesprache wird vom Typsystem erzwungen. Das ist sogar stärker als das String-Parsing von zuvor, denn dort ist eine beliebige Reihenfolge möglich. Folgender String etwa lässt sich schreiben und scheitert erst zur Laufzeit: "WHERE p.name = '$name' FROM Person p SELECT *".

Solche Typsicherheit ist auch für andere Anwendungsfälle interessant, etwa um (komplexe hierarchische) fachliche Datenstrukturen zu erzeugen. Darum geht es in diesem Abschnitt.

Angenommen, es soll eine TvSeries erzeugt werden. In Java ließe sich das so schreiben:

JTvSeries series = tvSeries(tvSeries -> {  
    tvSeries.setName(“Game of Clones”);
    tvSeries.addSeason(season -> {  
        season.setTagLine(“Here be Dragoons”);
        season.addEpisode(episode -> {  
            episode.setName(“Take flight”);
        });
    });
    tvSeries.addSeason(season -> {  
        season.setTagLine(“No more Dragoons”);
        season.addEpisode(episode -> {  
            episode.setName(“Watch out for the fan”);
        });
    });
});\

In Kotlin sähe dieselbe Datenstruktur so aus:

val series = tvSeries {  
    name = “Game of Clones”  
    season {  
        tagLine = “Here be Dragoons”
        episode {  
            name = “Take flight”  
        }  
    }    
    season {  
        tagLine = “No more Dragoons”  
        episode {  
            name = “Watch out for the fan”  
        }  
    }
}\

Beide Sprachen machen das Erzeugen dieser Datenstrukturen sicher, weil die selbst definierte Hierarchie nicht umgangen werden kann. So lässt sich zum Beispiel kein episode {} innerhalb von tvSeries {} anlegen, weil die Funktion nur innerhalb von season {} zur Verfügung steht. In Java gibt es allerdings ein ungewolltes Scoping-Problem: Kinder können die Builder der Eltern direkt aufrufen. tvSeries.setName lässt sich etwa aus der addSeason()-Lambda heraus aufrufen. In Kotlin lässt sich dieser unbeabsichtigte Missbrauch mit der Annotation @DslMarker verhindern, wie weiter unten erklärt.

Wo sich die beiden Codebeispiele unterscheiden, ist die Lesbarkeit, und das hängt an einigen Designentscheidungen von Kotlin (siehe Teil 3 für eine ausführlichere Betrachtung):

  • Properties, die das Setzen des Namens per Gleichheitszeichen erlauben
  • Operator Overloading
  • die Art, wie Lambdas aufgerufen werden
  • und dass diese Lambdas sogenannte Receiver haben können

Wenn man mit diesen Features Datenstrukturen erzeugt, heißt das in Kotlin type-safe Builder, sie sind von Groovy inspiriert und werden in vielen Kotlin-Projekten eingesetzt. Wer schon einmal:

  • dependencies { } in einer build.gradle.kts-Datei deklariert hat
  • einen router { GET("/users", findAllUsers) } in einem Spring-Boot-Kotlin-Projekt konfiguriert hat

hat bereits einen typsicheren Builder benutzt. Sie lassen sich auch für SQL, html {} und sogar Geschäftslogik nutzen, wie die vorherigen Codebeispiele zeigen.

Diese Builder sind leicht zu bauen. Zuerst wird tvSeries erzeugt, eine Funktion, die eine Funktion als Argument nimmt:

fun tvSeries(
    init: (builder: KTvSeriesBuilder) -> Unit // <1>
    //     ^ parameters of function      ^^^
    //                                   function returns nothing
): KTvSeries {  
    val builder = KTvSeriesBuilder() // <2>
    init(builder) // <3>
    return builder.build() // <4>
}\

Die Funktion tvSeries hat die Funktion init als Argument, die ihrerseits einen builder als Argument nimmt und nichts zurückgibt (Unit in Kotlin, void in Java) <1>. Innerhalb der Funktion entsteht dann ein KTvSeriesBuilder <2>, die init-Funktion setzt beliebige builder-Werte <3>, und schließlich wird das Objekt gebaut <4>.

In Java würde der Aufruf von tvSeries so aussehen:

KTvSeries series = tvSeries(series -> {  
    series.setName(“Game of Drones”); 
});\

In Kotlin bekommen Lambdas aber eine besondere Syntax, wenn sie der letzte Parameter einer Funktion sind: Sie können außerhalb der Klammern () geschrieben werden. Gibt es keine weiteren Parameter, lassen sich auch die Klammern () weglassen, und heraus kommt:

val series: KTvSeries = tvSeries { series ->  
    series.name = “Game of Drones”  // <1>
}\

Die an tvSeries {} übergebene Funktion kann beliebige series-Werte setzen <1>. Das ist schon etwas lesbarer als die Java-Lambda, es geht aber noch besser. In Kotlin lässt sich ein sogenannter Receiver für die Funktion deklarieren:

fun tvSeries(init: KTvSeriesBuilder.() -> Unit
    //             ^^^^^^^^^^^
    // added KTvSeriesBuilder as the receiver of the function
): KTvSeries {  
    // same as before
}\

Der Weg führte also

  1. von tvSeries(init: (builder: KTvSeriesBuilder) -> Unit) {}
  2. zu tvSeries(init: KTvSeriesBuilder.() -> Unit) {}

In beiden Fällen braucht die init-Funktion einen KTvSeriesBuilder. Im ersten Fall ist er ein Argument, auf das über den Namen zugegriffen wird (series.name = "abc"), im zweiten wird er zu einem expliziten oder impliziten this(this.name = "abc" oder einfach name = "abc").

Hat KTvSeriesBuilder nun seinerseits ein fun season(init: KTvSeasonBuilder.() -> Unit) und KTvSeasonBuilder ein fun episode(init: KTvEpisodeBuilder.() -> Unit), landet man beim Beispiel vom Anfang: eine lesbare und typsichere Art, hierarchische Konfiguration, generische Daten (HTML, XML, JSON) oder Geschäftsdaten zu konstruieren.

fun createCatalog(){
    // …
    tvSeries {  
        //     ^
        //     no parameter needed
        name = “Game of Drones”  
        // ^
        // KTvSeriesBuilder is the implicit this
        // We can access whatever the KTvSeriesBuilder has
        // Previously we still had to write series.name
        season {  
            tagLine = “Here be Dragoons”  
            episode { }
        }  
    }
    // …
}\

Ein Feature fehlt noch, denn das Verschachteln von season {} in tvSeries {} bringt ein Problem mit sich: Innerhalb einer Lambda lassen sich die Methoden jedes verfügbaren impliziten Receivers aufrufen. In season {} lässt sich zum Beispiel der name des äußeren tvSeries {} setzen. In Kotlin lässt sich der Scope des Receivers einschränken, indem @DslMarker an die Klasse, die Elternklasse oder den Receiver geschrieben wird. Im Codebeispiel steht die Annotation an der Elternklasse aller Builder.

@DslMarker  
@Target(AnnotationTarget.CLASS) 
annotation class TvDslMarker

@TvDslMarker  
interface KTvBuilder

class KTvSeasonBuilder : KTvBuilder { /** **/ }

class KTvSeriesBuilder : KTvBuilder { /** **/ }

class KTvEpisodeBuilder : KTvBuilder { /** **/ }\

Ist die Annotation gesetzt, meldet der Compiler einen Fehler, wenn implizit auf etwas aus dem übergeordneten Scope zugegriffen wird. Das verhindert eine Verletzung der Builder-Hierarchie.

Das Builder-Konzept selbst gibt es auch in Java, die lesbare und ergonomische Ausdrucksform ist aber Kotlin eigen. Das ist eine ganz gute Zusammenfassung dessen, was Kotlin bietet: vertraute Konzepte, nur sicherer und knapper ausgedrückt.

Dieser Abschnitt hat sich auf Builder konzentriert, einen oft übersehenen Weg zu Typsicherheit. Andere Formen der Typsicherheit, etwa Sealed Types, folgen in Teil 5, weil sich ihr Nutzen bei der Domänenmodellierung leichter zeigen lässt.

Zusammenfassung: Eine Sprache für Sicherheit

Eine der wirkungsvollsten Arten, wie eine Sprache die Sicherheit von Programmen verbessern kann, besteht darin, ungültige Zustände gar nicht erst darstellbar zu machen. In diesem Teil der Serie „Kotlin kontra Java“ ging es um fünf Bereiche, in denen die beiden Sprachen dabei unterstützen können.

BereichJavaKotlin
Speichersicherheitvon der JVM verwaltetvon der JVM verwaltet
Thread-Sicherheitmuss selbst sichergestellt werdenmuss selbst sichergestellt werden
NullsicherheitCompiler-Plugin kann JSpecify-Annotationen prüfen, aber keine Operator-Ergonomie. Garantie zur Laufzeit nur, wenn alle null-Prüfungen ergänzt und synchron gehalten werden. Keine Garantie für Java-Code von Dritten. Parameterloser Konstruktor kann die Garantie aushebeln. Dokumentierte bewusste Unsoundness.Erzwungene Compiler-Prüfungen in Kotlin-Code und Operator-Ergonomie (?., ?:). Garantie zur Laufzeit, weil der Compiler die null-Prüfungen ergänzt und synchron hält. Garantie für Kotlin-Code von Dritten. Parameterloser Konstruktor kann die Garantie aushebeln. Dokumentierte Unsoundness überwiegend bei der Interaktion mit Java-Code.
Injection-Sicherheit und sicheres Einbetten von VariablenMuss selbst sichergestellt werden. Kurze Previews für String Templates, aber nie fertiggestellt.Muss selbst sichergestellt werden. Derzeit keine Custom String Template Pläne.
Typsicherheit durch BuilderMöglich, aber verboser; äußere Builder sind aufrufbarCompiler-Unterstützung und Sprachfeatures machen sie besonders ausdrucksstark

Kurz gesagt: Kotlin profitiert von denselben Sicherheitsgarantien auf JVM-Ebene wie Java und erzwingt zusätzlich Nullsicherheitsprüfungen für sämtlichen Kotlin-Code. Damit liegt Kotlin beim Thema Sicherheit vorn, sobald es mit anderem Kotlin-Code oder mit null-annotiertem und geprüftem Java-Code arbeitet. Hinzu kommen ergonomische typsichere Builder durch Compiler-Unterstützung und Sprachdesign. Im fünften und letzten Teil geht es darum, wie die Designentscheidungen beider Sprachen zusammenkommen, wenn die Möglichkeiten von Java und Kotlin zur Modellierung von Geschäftslogik erkundet werden.