Wie man den Milliarden-Dollar-Fehler repariert

Richard Gross

Es gibt Fehler, die sind einfach nur teuer. Und dann gibt es die Nullreferenz. Ein Sprachfeature, das 1965 eingeführt wurde – „einfach, weil es so leicht zu implementieren war“. Seitdem hat die Nullreferenz unzählige Bugs, Systemabstürze und Sicherheitsprobleme hervorgerufen – und Milliarden an Kosten für Wartung und Fehlersuche verursacht. In vielen modernen Programmiersprachen wurde die Referenz daher bereits eliminiert. Dieser Artikel zeigt praktische Wege, wie sich dieser Sprachfehler auch in Java vermeiden lässt.

Nullreferenzen: ein historischer Fehler mit dramatischen Folgen

Sir Tony Hoare ist bekannt für die Entwicklung des Quicksort-Algorithmus, für die Hoare-Logik (ein Regelwerk zur formalen Überprüfung der Korrektheit von Programmen) und für das Formulieren des „Dining Philosophers“-Problems zusammen mit Edsger Dijkstra. Ich entschuldige mich, falls diese Zusammenfassung seines immensen Beitrags zur Softwaretechnik zu kurz geraten ist. Vielleicht wird sein Beitrag klarer, wenn ich erwähne, dass er Träger des Turing Awards ist – den er für seine „grundlegenden Beiträge zur Definition und Gestaltung von Programmiersprachen“ erhielt.

Eine der Sprachen, an denen er 1965 arbeitete, war ALGOL W – insbesondere dessen umfassendes Typsystem für Referenzen in objektorientierten Sprachen. Dieses Typsystem prüfte bereits zur Kompilierzeit jede Referenz darauf, ob sie auf etwas Existierendes verwies und der Typ korrekt war. Dadurch wurde eine ganze Klasse potenzieller Fehler vollständig ausgeschlossen.

Fall geschlossen, Nullreferenzen vermieden – eigentlich. Wenn da nicht die Tatsache gewesen wäre, dass Tony für ein Unternehmen arbeitete, das ALGOL-Computer verkaufte. Die meisten potenziellen Kunden nutzten jedoch keine ALGOL-, sondern Fortran-Programme. Um diese Programme auf den ALGOL-Rechnern laufen zu lassen, wurde ein Transpiler von Fortran nach ALGOL entwickelt. Nach etwa einem Jahr war das Tool fertig – doch kein Kunde wollte es einsetzen. Denn die Fortran-Programme wurden nicht transpiliert, sondern warfen allesamt Syntaxfehler zur Kompilierzeit. Alle ausgelöst durch Referenzen, die entweder auf null zeigten oder den falschen Typ hatten.

Das ist natürlich super. Der Kompiler hat möglichst früh eine ganze Reihe von gefährlichen Fehlern aufgedeckt.

Die Kunden wollten aber, dass ihre Programme laufen. Sie wollten nicht erst alle Probleme beheben. Und Tonys Firma wollte Computer verkaufen. Also führte er die Nullreferenz ein. Dabei war das nicht die einzige Lösung für das Problem. Schon damals kannte Sir Tony Hoare eine Alternative: disjunkte Vereinigungen (disjoint unions). Aber „null“ war einfacher umzusetzen.

Natürlich musste man nach der Umsetzung faktisch jede Referenz zur Laufzeit prüfen (oder bei ungeprüftem Zugriff eine Katastrophe riskieren) aber man konnte so jedes Fortran-Program auch auf ALGOL-Maschinen ausführen.

Das Problem war, dass ALGOL viele andere Programmiersprachen inspirierte – etwa C oder Simula. Simula wurde als „ALGOL 60 mit Klassen“ vermarktet und gilt als erste objektorientierte Programmiersprache. Sie war zudem eine wichtige Inspiration für Bjarne Stroustrup, den Erfinder von C++, und James Gosling, den Schöpfer von Java. All das führt dazu, dass sich Sir Tony Hoare im Jahr 2009 öffentlich für die Erfindung der Nullreferenz entschuldigte. Wohlgemerkt auf durchaus humorvolle Weise.

Die tatsächlichen Kosten

Die Nullreferenz und die zugehörige Exception zur Laufzeit ist in den meisten modernen Programmiersprachen vertreten. Doch ist der Fehler tatsächlich so teuer wie Sir Hoare denkt?

Schauen wir uns zunächst die Liste der CWE Top 25 Most Dangerous Software Weaknesses an. Sie zeigt die aktuell häufigsten und folgenreichsten Schwachstellen von Software. Die Platzierungen ändern sich von Jahr zu Jahr, da die Liste auf den rund 30.000 gemeldeten Common Vulnerabilities and Exposures (CVE) eines Jahres basiert. Die Null Pointer Dereference war dabei auf Platz 21 (2024)12 (2023)11 (2022)15 (2021) und 13 (2020) vertreten. Diese Liste betrachtet allerdings „nur“ die Kosten von Nullreferenzen die Sicherheitslücken waren. Bekanntermaßen ist Wartung und Fehlersuche von Bugs und Systemabstürzen auch sehr kostspielig.

Doch wie kostspielig sind Bugs durch NullReferenzen? Das Systems Sciences Institute von IBM hat ermittelt, dass es bis zu 100 Mal mehr kostet einen Bug in Produktion zu beheben als während des Designs. Viele Bugs zeigen sich als Exceptions und für diese haben wir sogar Häufigkeiten, dank einer Auswertung von Harness (früher OverOps). 2020 analysierte das Unternehmen anonymisierte Statistiken von über tausend Java-Anwendungen, die sie überwacht hatten. Das Ergebnis: 97 % aller protokollierten Fehler gingen auf nur zehn unterschiedliche Fehlertypen zurück. Der Spitzenreiter war die NullPointerException – sie belegte in 70 % der Java-Produktionsumgebungen den ersten Platz.

Betrachten wir als letztes ein Problem, dass weltweit verbreitetet war aber “nur” ein paar Jahre sehr viel Einsatz benötigt hat: den Year-2000-Bug. Dieser hat Schätzungen zufolge rund 300 Milliarden US-Dollar gekostet.

Legt man diese drei zusammen, können wir die Hochrechnung von Tony nachvollziehen. Die Nullreferenz ist nicht nur in der Top 15 der ausgenutzten Sicherheitslücken, sondern auch der Spitzenreiter für gefundene Bugs in Produktion. Da es sie seit 60 Jahren gibt, scheint eine Größenordnung ähnlich dem Y2K-Bug plausibel. Die NullReferenz kann durchaus Milliarden an Kosten für Wartung und Fehlersuche verursacht haben.

Im nächsten Abschnitt gehen wir der Frage nach, warum genau Wartung und Troubleshooting so teuer sind.

Der Fehler liegt nicht dort, wo du ihn findest

Sieh dir folgenden Code an. Kannst du erraten, wo der Fehler liegt?

public String findNotebookMaker(EmployeeId id) {   
    var employee = company.getById(id);
    if (employee == null)
        return "Employee does not exist";
    /* ... */
    return employee.notebook().maker();
}

Das Problem ist folgendes:

    return employee.notebook().maker();
                   ^^^^^^^^^^^
    // java.lang.NullPointerException: 
    // Cannot invoke "Notebook.maker()" because the return value of "Employee.notebook()" is null

Was unmöglich ist.

Ein Mitarbeiter hat immer ein Notebook!!! So haben wir das schließlich designt! — verwirrte Developer

Unter bestimmten Bedingungen (z. B. wenn das Notebook gerade in Reparatur ist) hat ein Mitarbeiter eben kein Notebook. Die verwirrten Developer kannten nur den Zustand vor Einführung dieses Features. Für sie hatte ein Mitarbeiter immer ein Notebook. Oder sie haben es einfach vergessen, korrekt umzusetzen. Am Ende spielt der Grund für das Unwissen aber keine Rolle. Die Exception wurde trotzdem ausgelöst – und sie ist nicht das eigentliche Problem.

Das wahre Problem liegt hier:

public void startNotebookRepair(EmployeeId id) {   
    var employee = company.getById(id);
    if (employee == null)
        return;

    /* ... */
    company.put(id, employee.withNotebook(null));
    //                the actual problem  ^^^^
}

Das Problem liegt nicht dort, wo die Exception ausgelöst wird – sondern dort, wo null gesetzt wird. In produktivem Code gibt es oft viele Bedingungen, die dazu führen können, dass null gesetzt wird.

Zuerst müssen wir herausfinden, welche Bedingung in findNotebookMaker zu unserem Nullpointer führt. Dann müssen wir entscheiden, ob das Setzen von null in startNotebookRepair korrekt war – oder ein Fehler. Falls es ein Bug war, bleibt nur zu hoffen, dass keine andere Bedingung ebenfalls das Notebook auf null setzt, denn das würde zur nächsten Exception führen. War es hingegen korrekt, müssen wir festlegen, wie findNotebookMaker auf ein null-Notebook reagieren soll. Wenn wir einfach return null; schreiben, haben wir die Entscheidungsverantwortung nur erneut weitergereicht.

Stacktraces sind bei NullPointerExceptions offensichtlich nutzlos. Sie zeigen nicht, was das Problem verursacht hat, sondern nur wo es auftritt. Zum Beheben müssen wir den tatsächlichen Ursprung finden – und dann das Design entsprechend anpassen. Genau das macht Nullpointer so teuer.

Der falsche Weg ist wirklich jede Referenz vor dem Zugriff immer auf null zu prüfen und ständig Abfangstrategien zu schreiben. Der bessere Weg ist statisch kenntlich zu machen was null sein kann. Dadurch kann der Compiler uns ermahnen, wenn etwas von non-nullable zu nullable wechselt oder umgekehrt. Wir Developer können dann nicht mehr vergessen etwas zu tun.

Der Trend zu mehr Sicherheit in Programmiersprachen

Wir haben bereits gesehen, dass ALGOL W ein umfassendes Typsystem bot – das leider dennoch die Nullreferenz zuließ. Diese Referenz war ein „Feature“, das sich in den meisten populären Programmiersprachen bis heute gehalten hat. Doch ab etwa dem Jahr 2000 entstanden Sprachen, die den Umgang mit null bewusst hinterfragten.

Im Jahr 2005 erschien F#, eine neue funktionale Programmiersprache für .NET. Sie verspricht, „allen zu ermöglichen, prägnanten, robusten und performanten Code zu schreiben“. F# kennt das null-Schlüsselwort zwar für die Interoperabilität mit C# oder VB.NET, aber es ist nicht als regulärer Wert erlaubt. Stattdessen verwendet F# den option-Typ.

2010 kam Rust auf den Markt, “eine Sprache, die es allen ermöglicht, zuverlässige und effiziente Software zu entwickeln.“ In Rust gibt es keine null-Referenzen. Stattdessen arbeitet Rust mit optional Pointern.

Ein Jahr später, 2011, erschien Kotlin – eine „moderne, prägnante und sichere Programmiersprache“. Kotlin kennt zwar null aber man kann es nur Variablen zuweisen, die explizit als nullable deklariert wurden..

Robust, zuverlässig, sicher – hier zeichnet sich ein klarer Trend ab. Neue Programmiersprachen entstehen mit dem Wissen aus der Vergangenheit. Doch wie sieht es bei den älteren Sprachen aus?

Alle versuchen, mit null umzugehen

Laut Umfragen von RedmonkTIOBE und PYPL zählen Jahr für Jahr Python und Java zu den Top 5 Programmiersprachen – meist ist auch C# dabei.

Diese Sprachen sind inzwischen zwischen 20 und 31 Jahre alt. Zum Glück haben sie sich nicht auf ihren Lorbeeren ausgeruht, sondern sich weiterentwickelt – insbesondere, was den Umgang mit null betrifft.

Timeline der null-check Verbesserungen

Python versucht, mit null umzugehen

Python, vermutlich am bekanntesten dafür, alles einfach und unterhaltsam zu machen (siehe xkcd 353 als “Beweis”) nutzt dynamische Typisierung. Umso überraschender war es, als 2015 type hints eingeführt wurden. Ab Python 3.5 konnte man damit festlegen, welche Typen Inputs und welche Outputs sind: def greeting(name: str) -> str:.

Man konnte auch deklarieren, dass etwas Optional[T] ist und somit None sein darf – Pythons Version von null. Diese Hinweise informieren einen, dass ein Zugriff potenziell auf ein None treffen kann. Allerdings gilt nach wie vor: no type checking happening at runtime. Es geht nur um die Kompilierzeit.

C# versucht, mit null umzugehen

C#, gewissermaßen der „Cousin“ von Java, hat sich nie vor der Weiterentwicklung gescheut. Bereits 2005 führte die Sprache Nullable-value-Typen ein – also die Möglichkeit, sogar primitive Datentypen als nullable zu deklarieren (erlaubt: int? number = null;). Erst 2019 kamen dann Nullable-reference-Typen hinzu. Damit lassen sich Klassen entweder als nicht-nullbar (nicht erlaubt: User user = null;) oder als nullable (erlaubt: User? user = null;) deklarieren.

Der Compiler erzwingt, dass ein nicht-nullbarer Typ initialisiert werden muss und niemals null sein darf. Zudem wird sichergestellt, dass vor dem Zugriff auf eine Nullable-Referenz immer ein null-Check erfolgt – hier kommen dann auch ?.-Operatoren oder der Null-Koaleszenz-Operator ?? zum Einsatz.

In C# könnte das Finden des Notebook-Herstellers so aussehen:

public string? FindNotebookMaker(EmployeeId id) {   
    /* ... */
    return employee.notebook?.maker();
    //                        ^ 
    //              returns null, if notebook is null
}

Oder es könnte so aussehen:

public string FindNotebookMaker(EmployeeId id) {   
    /* ... */
    return employee.notebook?.maker() ?? "No maker";
    //                                ^^ 
    //     returns left side if non-null otherwise the "No maker" 
}

Im ersten Beispiel wird das Null-Handling an den Aufrufer der Methode delegiert. Im zweiten Beispiel wird entschieden, dass ein Standardwert die richtige Wahl ist. Keine der beiden Varianten ist „besser“ – auch andere Lösungen wären denkbar. Aber: Wir müssen eine Entscheidung treffen.

Nullable-Typen zwingen uns, diese Entscheidung vorab zu treffen. Dadurch vermeiden wir Laufzeitfehler, weil wir das Problem bereits beim Design des Features durchdenken – und genau dann haben wir das meiste Wissen über die Anforderungen.

Seit 2019 führen Nullable-Typen zu besseren C#-Designs. Aber nicht in allen Fällen, denn wenn man die Funktion pauschal für alle Projekte aktivieren würde, gäbe es unzählige Compile-Fehler – niemand würde auf C# 8 umsteigen. Dasselbe Problem hatte damals auch der Fortran-zu-ALGOL-Transpiler. Deshalb sind Nullable-Typen in C# nur in einem nullable aware context aktivierbar, der sich schrittweise pro Modul oder sogar pro Datei ein- und ausschalten lässt.

Java versucht, mit null umzugehen

Java – bekannt dafür, bei der Abwärtskompatibilität keine Kompromisse einzugehen – dürfte es am schwersten haben, den Milliardenfehler zu beheben. null ist so tief in die Sprache eingebrannt, dass es “sechs miteinander verknotete Doktorarbeiten bräuchte, um das Problem anzugehen.“.

Praktischerweise hat sich ein JDK-Projekt genau diesen „Doktorarbeiten“ in den letzten zehn Jahren gewidmet – und ihre Ergebnisse sollen in den kommenden Jahren als Preview veröffentlicht werden. Wünschenswert wäre aber natürlich, wenn wir schon heute etwas dagegen tun könnten.

Java hat OptionalReturn

Etwas, das bereits seit 2014 im JDK verfügbar ist, ist Optional<T>. Dieser Typ eignet sich hervorragend, um APIs lesbarer und flüssiger zu gestalten – aber er eliminiert NullPointerExceptions nicht.

Für optionale Felder oder Parameter ist Optional nicht geeignet. Warum das so ist, erkennt man erst, wenn man zur ursprünglichen Designabsicht zurückkehrt. Optional hätte eigentlich OptionalReturn<T> heißen sollen – also optionale Rückgabewerte von Methoden. Es wurde zusammen mit der Streams-API in Java 8 entwickelt. Das folgende Beispiel zeigt warum es gerade bei Streams gebraucht wird:

String employeeNameById(EmployeeId id){
    return employees.stream()
                    .search(it -> it.id() == id)
                    .name();
    //              ^^^^^^^
    // non-obvious potential NullPointerException
}

search() gibt entweder das Suchergebnis zurück – oder nichts, wenn es keinen Treffer gibt. Der Zugriff auf .name() der Person könnte also zur Laufzeit ein Nullpointer hervorrufen. Dieses Beispiel, bei dem „kein Ergebnis“ durch null dargestellt wird, ist allerdings fiktiv. search() hat es so nie in die API geschafft.

In der tatsächlichen Streams-API wurde ein Mechanismus eingeführt, um „kein Ergebnis“ darzustellen und dabei die API fluent zu halten (siehe auch: Optional – the mother of all bikesheds). Methoden, die eventuell „kein Ergebnis“ liefern, können Optional<T>zurückgeben. So hat der Aufrufer die Wahl, wie er damit umgehen möchte:

String employeeNameById(EmployeeId id){
    return employees.stream()
                    .filter(it -> it.id() == id)
                    .findFirst()
                    // returns an Optional
                    .map(Employee::name)
                    .orElse(“UNKNOWN”);
}

Weniger geeignet ist Optional allerdings für die andere Richtung – nämlich wenn man es als Parameter übergeben will:

myMethod(a, Optional.ofNullable(b), c, Optional.ofNullable(d));

Dieser Methodenaufruf ist jetzt ziemlich unübersichtlich. An einer stelle ist das natürlich nicht katastrophal, aber ein weitverbreitete Einsatz von Optional.of macht den Code schnell sehr unordentlich.

Noch schlimmer wird es, wenn man mit optionalen Feldern interagieren muss:

if(sth.a().isEmpty()
    && sth.b().isPresent()
    && sth.b().get().bb().isPresent()){

    aMethod(sth.b().get(), sth.c().get());
}

Statt wirklich zu helfen, ist Optional<T> eher zu einer Krücke geworden, die den Code unnötig aufbläht. Und obendrein verhindert sie NullPointerExceptions nicht zuverlässig – denn wir können nach wie vor Folgendes schreiben:

record Card(String name, Optional<Integer> value){}
void play(Optional<Card> card){ }

void doSomething(){
    var card = new Card("Ace", null);
    //                         ^^^^
    //               perfectly valid
    play(null);
    //   ^^^^
    // same for this
}

Es wäre deutlich besser, wenn wir die Information über „nullability“ beibehalten könnten – ohne den ganzen Optional-Ballast aber mit echtem Schutz vor NullPointerExceptions. Genau hier kommen NullAway und JSpecify ins Spiel.

Null-Prüfungen zur Compile-Zeit mit NullAway und JSpecify

NullAway ist ein statisches Analyse-Tool zur Nullprüfung zur Compile-Zeit. Entwickelt wurde es von Uber, weil man dort ein Werkzeug brauchte, um NullPointerExceptions großflächig zu eliminieren (siehe ihre 2019 paper). Ihren Angaben zufolge liegt der zeitliche Overhead beim Bauen unter 10 %, sodass das Tool bei jedem Build mitlaufen kann.

NullAway ist ein Plugin für error prone, einen statischen Code-Checker von Google. Um es zu verwenden, muss es als Plugin in die gradle- oder maven-Konfiguration aufgenommen werden (vollständiges Beispiel):

// add processor to pom.xml (or .gradle):
// abbreviated for readability, full example in Github
<build><plugins>
<plugin>
    <artifactId>maven-compiler-plugin</>
    <configuration>
        <compilerArgs>
            <arg>
                -Xplugin:ErrorProne
                -Xep:NullAway:ERROR
                -XepOpt:NullAway:AnnotatedPackages=de.richargh.module-a
            </>
        </>
        <annotationProcessorPaths>
            <path>
                <groupId>com.google.errorprone</>
                <artifactId>error_prone_core</artifactId>
                <version>${error-prone.version}</version>
            </>
            <path>
                <groupId>com.uber.nullaway</>
                <artifactId>nullaway</artifactId>
                <version>${nullaway.version}</version>
            </>

NullAway geht nun davon aus, dass alle Variablen im Package de.richargh.module-x standardmäßig nicht nullbar sind. Jede Codezeile, in der null an ein nicht-nullbares Feld übergeben wird, führt zu einem Compile-Fehler wie: [NullAway] passing @Nullable parameter 'null' where @NonNull is required

Im nächsten Schritt können wir dann gezielt die Felder als nullable markieren, bei denen das auch wirklich zutrifft. Bis 2024 hätte man dafür meist den Quasi-Standard JSR-305 verwendet – ein Annotations-Set, das ursprünglich vom Static-Analyser FindBugs vorgeschlagen wurde. Obwohl weit verbreitet (Maven Central listet Pakete von Google, Atlassian, Eclipse und vielen anderen), ist die Spezifikation seit 2012 inaktiv geblieben und wurde nie finalisiert. Die Annotationen wurden nie vollständig definiert.

Bis schließlich JSpecify das Thema wieder aufgegriffen hat.

JSpecify ist ein Set aus Annotationen, Spezifikationen und Dokumentation für die statische Codeanalyse. Es wird unter anderem von Google, Oracle, Uber (NullAway), Broadcom (Spring), JetBrains, PMD, Sonar und vielen weiteren definiert. Die Version 1.0.0 erschien 2024 und bringt vier Annotationen zur Nullprüfung mit, die sich typischerweise in folgenden Kombinationen einsetzen lassen:

  • Um Typen in einem Package, einer Klasse, Methode oder einem Konstruktor standardmäßig als nicht-nullbar zu deklarieren, verwendet man @NullMarked. Alles, was null sein darf, muss dann explizit mit @Nullable gekennzeichnet werden.
  • Um Typen standardmäßig als nullbar zu deklarieren, nutzt man @NullUnmarked. Alles, was nicht null sein darf, muss dann explizit mit @NonNull markiert werden.

Um diese Annotationen mit NullAway zu verwenden, reicht es, sie als Abhängigkeit einzubinden:

// abbreviated for readability
<dependencies>
    <dependency>
      <groupId>org.jspecify</groupId>
      <artifactId>jspecify</artifactId>
      <version>1.0.0</version>
    </dependency>
</>

Keine weitere Konfiguration ist notwendig – schließlich ist Uber, der Entwickler von NullAway, auch Mitglied des JSpecify-Projekts. Genauso wie Broadcom, die das Spring Framework entwickeln. Die nächste Spring-Version liefert aktuell die ersten Meilenstein-Releases aus, in denen die bisherigen JSR305-Annotationen vollständig durch JSpecify ersetzt wurden. Auch sie nutzen NullAway, um diese Regeln durchzusetzen. Ob Spring Boot 4 ebenfalls durchgehend auf die neuen Annotationen setzen wird, ist zum Zeitpunkt dieses Textes noch offen.

Das Milliarden-Dollar-Problem mit dem Compiler beheben

Sobald die Annotationen eingebunden sind, muss man sie nur noch konsequent einsetzen – etwa durch das Markieren von Werten als @Nullable. Unser Beispiel mit der Notebook-Reparatur sähe dann so aus:

/// Shop.java v2
public void startNotebookRepair(EmployeeId id) {&nbsp;&nbsp;&nbsp;
    var employee = company.getById(id);
    if (employee == null)
        return;

    /* ... */
    company.put(id, employee.withoutNotebook());
}

/// Employee.java v2
public record Employee(
    EmployeeId id,
    String name,
    @Nullable Notebook notebook) {
//  ^^^^^^^^
// new JSpecify annotation

    /* withoutNotebook() … */
}

Das bedeutet: Folgendes führt zu einem Compile-Fehler – noch bevor der Code überhaupt läuft:

// v1
public String findNotebookMaker(EmployeeId id) {&nbsp;&nbsp;&nbsp;
    var employee = company.getById(id);
    if (employee == null)
        return "Employee does not exist";
    /* ... */
    return employee.notebook().maker();
    //                        ^^^^^^^^
    // [NullAway] dereferenced expression employee.notebook() is @Nullable
}

Und wir sind gezwungen, eine Lösung zu entwerfen:

// v2
public String findNotebookMaker(EmployeeId id) {&nbsp;&nbsp;&nbsp;
    var employee = company.getById(id);
    if (employee == null)
        return "Employee does not exist";
    /* ... */
    return employee.notebook() != null
                ? employee.notebook().maker()
                : EMPLOYEE_DOES_NOT_HAVE_A_NOTEBOOK;
}

Der Compiler war uns eine enorme Hilfe. Er hat uns gezwungen, die Ursache zu beheben – nicht bloß das Symptom. Wir mussten explizit festlegen, ob das Setzen von null überhaupt erlaubt sein sollte.

Diese Prüfung zur Compile-Zeit ist derselbe Ansatz, den auch Rust, F# und Kotlin verfolgen – und auf den C# später umgestellt hat. Sicher, jede Sprache nutzt dafür eine eigene Syntax – aber die Grundidee ist immer dieselbe. Genau genommen ist es die Idee, die Sir Tony Hoare bereits 1965 hatte.

Schrittweise Migration

NullAway zu einem Code-Base hinzuzufügen, ist großartig. Man entdeckt eine Menge Fehler, die bislang offen, aber unbemerkt im Code lagen. Für jedes Greenfield-Projekt sollte das Pflicht sein. Doch es auf einen gewachsenen Bestand von tausenden oder gar Millionen Zeilen anzuwenden – das wäre der blanke Horror.

Genau hier kommen die Konfigurationsmöglichkeiten von NullAway ins Spiel. Alle Optionen beginnen mit dem Präfix -XepOpt:NullAway.

Der einfachste Einstieg gelingt mit :AnnotatedPackages=de.richargh.module-a,de.richargh.module-b – hier geben wir eine durch Kommata getrennte Liste von Packages an, die überprüft werden sollen. Man startet mit einem Modul oder sogar nur einem kleinen Teil davon und erweitert schrittweise. Wenn man erst einmal ein Gefühl für NullAway bekommen will, beginnt man mit einem einfachen Modul. Besser ist es jedoch, direkt in einem geschäftskritischen Modul zu starten. Selbst wenn die Migration dort länger dauert, ist der Nutzen deutlich höher – und damit auch leichter intern zu argumentieren.

Sobald genug Module migriert sind, kann man die Perspektive umdrehen: Statt explizit Packages einzuschließen, kann man alle aktivieren und nur die noch nicht migrierten ausklammern mit::UnannotatedSubPackages=de.richargh.module-y,de.richargh.module-z :AnnotatedPackages=de.richargh.

Eine Alternative zu AnnotatedPackages ist es, direkt mit JSpecify-Annotationen zu arbeiten. Dafür ersetzt man :AnnotatedPackages= durch :OnlyNullMarked=true. Dann markiert man die gewünschten Module über ihre jeweilige package-info.java-Datei:

@NullMarked 
package de.richargh.module-a; 

import org.jspecify.annotations.NullMarked;

// place this package-info.java into the root of the module you want to mark

Früher oder später wird man auch hier den Schalter umlegen wollen – also das gesamtes Projekt mit @NullMarked versehen und gezielt nur noch die nicht überprüften Module ausschließen. Das geht dann z. B. so:

@NullUnmarked 
package de.richargh.module-z; 

import org.jspecify.annotations.NullUnmarked;

Egal, für welchen Ansatz man sich entscheidet – man muss vorsichtig mit Objekten sein, die aus nicht markierten Modulen stammen. NullAway hat keine Informationen über die Nullbarkeit von Objekten außerhalb des aktuellen Moduls. Es lässt daher zu, dass man auf sie ohne Prüfung zugreift.

Es liegt also im eigenen Interesse, Module, die häufig Daten untereinander austauschen, möglichst früh zu migrieren. Ein Paradebeispiel ist ein Modul, das von mehreren anderen verwendet wird. Solange dieses gemeinsame Modul nicht mit NullAway geprüft wird, kann man auch in keinem anderen Modul dem Output wirklich trauen – selbst wenn das andere Modul bereits NullAway aktiviert hat. Andernfalls steht die eigene Nullsicherheit immer auf wackligem Fundament.

6+1 Tipps, um das Maximum aus Null-Checks herauszuholen

(0) Vertrau nichts jenseits der Zone Alles, was nicht von NullAway geprüft wird, liegt außerhalb der sicheren Zone. Wie bereits erwähnt, gilt das auch für den eigenen nicht annotierten Code – und erst recht für den Großteil an Drittanbieter-Bibliotheken. Das Spring Framework ist eine seltene, annotierte Ausnahme. Besonders tückisch: Deserialisiertes JSON. Mapping-Frameworks wie Jackson betrachten Null-Checks als Validierung – und genau das macht Jackson nicht:

/// RenterDto.java
public record RenterDto(
    String id,
    String name){
}

/// somewhere else, perhaps in a Controller
var json = """{ "name": :"Alex" }""";
var result = objectMapper.readValue(json, RenterDto.class);
//                                  ^^^^
//                            does not throw an exception
// result = RenterDto[id=null, name=Alex]
result.id() == null; // true…

Man sollte also genau wissen, was an den Übergängen passiert. Besonders bei Jackson empfiehlt es sich, zusätzliche Validierungsprüfungen einzubauen. Dafür kann man zum Beispiel den Hibernate Validator verwenden und einen eigenen ConstraintValidator schreiben. Ein Beispiel dafür ist dieser benutzerdefinierte DefaultIsNonNullableValidator – perfekt als Einstieg.

(1) Nicht-null als Standard setzen NullAway geht standardmäßig von diesem Fall aus. Trotzdem lohnt es sich, das klar festzuhalten: Optionalität ist die Ausnahme, nicht die Regel.

(2) Optionale Fields mit @Nullable modellieren Verwende nicht Optional<T>, sondern bleibe bei Annotationen:

public class {coolClass} {
    private @Nullable Address address;
}

public record {coolRecord} (
    @Nullable Address address){
}

(3) Gib niemals null weiter – nutze stattdessen explizite Methoden

// if you have this method:
var p1 = notebookPriceFor(
    employeeId, notebookType);

// ❌ then do not do this:
var p2 = notebookPriceFor(
    null, notebookType);
//  ^^^^
// What does passing null mean semantically? 

// ✅ create an explicit method that indicates that a query without an employee, is just an estimate
var p2 = estimateNotebookPrice(
    notebookType);

(4a) Rückgabewerte von Methoden als nullable markieren  Wenn eine Methode null zurückgeben kann, dann muss sie auch klar als solche gekennzeichnet sein – etwa mit @Nullable. Alles andere führt zu Missverständnissen und potenziellen NullPointerExceptions beim Aufruf.

@Nullable {CoolResult} myMethod(A a, B b) {
    /*…*/
}

(4b) Optional gelegentlich für Rückgabewerte von Methoden verwenden – das gibt dem Aufrufer Entscheidungsspielraum Stell dir folgende Repository-Methode vor:

// Optional is quite useful for repositories
Optional<Address> getAddressById(employeeId id) {
    return Optional.ofNullable(addresses.get(id));
}

Dann hat der Aufrufer alle Optionen:

// Choice A: caller says null is not allowed
var account = accountsfindAccountById(id).orElseThrow();

// Choice B: caller says default is possible
var account = accounts.findAccountById(id).orElse(Account.none());

// Choice C: caller uses fluent chain
var budget = accounts.findAccountById(id)
                     .map(Account::budgetId)
                     .flatMap(budgets::getById)

(5) Leere Collections oder Arrays zurückgeben – niemals null

// ❌ not like this
// just makes the API more difficult to use for all callers
@Nullable List<Address> recentAddresses(){
    return employee.isTrackingAllowed()
                    ? recentAddresses
                    : null;
}

// ✅ like this
List<Address> recentAddresses(){
    return employee.isTrackingAllowed()
                    ? recentAddresses
                    : Collections.emptyList();
}

(6) JDK-16-Records für Domain-Typen verwenden

/// Address.java
public record Address(
    String city,
    @Nullable String street
) {
}

/// somewhere-else.java
var address1 = new Address(
    “Brühl”, "Berggeiststraße 31-41");
var address2 = new Address(
    “Brühl”);
//         ^^
// Compile-time error because parameter is missing
// null is allowed, we just have to be explicit about it

Beim Arbeiten mit Null-Checks geht es darum, Mehrdeutigkeiten schrittweise zu reduzieren. Eine typische Quelle solcher Unklarheiten sind teilweise initialisierte Domain-Objekte. Genau das verhindern Records – sie verlangen, dass alle Felder deklariert und beim Erzeugen befüllt werden, selbst wenn sie null sein dürfen. Zudem sind Records sehr kompakt und ersparen viel Boilerplate-Code. Falls Records viele mögliche Startzustände haben, sollte man über statische Factory-Methoden nachdenken, um die Erstellung zu strukturieren.

Die Zukunft – Sechs verknotete Doktorarbeiten

Als ich zuletzt über die Behebung des Milliarden-Dollar-Fehlers sprach (JavaLand 2023), hoffte ich, dass wir bis etwa 2029 mit Null-Sicherheit im JDK rechnen könnten.

Knapp ein Jahr später hielt Brian Goetz, Spracharchitekt von Java, einen Vortrag über Java’s Epic Refactor. Darin gab er große Updates zu Project Valhalla – auch bekannt als die “sechs miteinander verknoteten Ph.D.-Arbeiten.”. Ziel vom Projekt ist es sogenannte Value Classes in Java einzuführen – Klassen, die sich „wie ein int verhalten, aber wie eine Klasse aussehen“. Ein ganz anderes Ziel als Null-Marking. Und doch kündigte Brian in diesem Zusammenhang auch die Einführung von Null-Restricted und Nullable Types in Java an.

Sobald diese Erweiterung im JDK landet, werden wir dasselbe erreichen können, was uns heute NullAway zusammen mit JSpecify bietet – aber ohne NullAway oder JSpecify und mit einer deutlich kompakteren Syntax. Anstelle von @Nullable würde man Typen einfach mit ? kennzeichnen, und statt @NonNull mit !.

Und anstelle eines Compiler-Plugins übernimmt dann der Compiler selbst die Prüfung. Unser Employee sähe dann zum Beispiel so aus:

public record Employee(
    EmployeeId! id,
    String! name,
    Notebook? notebook) {
}
// very similar to how employee would be modelled in C# or Kotlin. 

Die ! sind im gerade entwickelten Ansatz notwendig, um etwas als null-restricted zu kennzeichnen. Ohne diese Marker bleibt der Code rückwärtskompatibel – die Nullbarkeit eines Typs ist dann einfach unspecified.

Wünschenswert wäre in Java auch etwas wie @NullMarked: Eine Möglichkeit, standardmäßig von nicht-nullbar auszugehen und nur tatsächlich nullable Typen mit ? markieren zu müssen. Genau das ist auch Teil der vorgeschlagenen Weiterentwicklungen.

Bevor es jedoch zu dieser Vereinfachung kommen kann, müssen erst einmal die eigentlichen Null-Restricted and Nullable Types implementiert werden – und genau das ist der Knackpunkt. Diese Typen sind nämlich nicht das Hauptziel von Project Valhalla. Sie sind vielmehr ein notwendiges Mittel, um die neuen Value Types besonders mächtig zu machen. Es ist gut möglich, dass diese Typen ihren Weg ins JDK finden – sicher ist das allerdings nicht. Die finale Entscheidung und Umsetzung liegt beim Valhalla-Projektteam.

Bis dahin bleibt NullAway eine hervorragende Möglichkeit, den Milliardenfehler endlich zu beheben.

Quellen

  • NullAway bietet eine gute Dokumentation für den Einstieg
  • Ein vollständiges Beispiel mit NullAway und Maven findet sich hier
  • Frühere Präsentationen, Folien und Aufzeichnungen zum Thema gibt es hier
Total
0
Shares
Previous Post

Was macht Vaadin-Komponenten besonders?

Next Post

Testcontainer Tests in Lichtgeschwindigkeit: Tests schneller und flexibler gestalten

Related Posts

Ein Ansatz für Cloud-Transformation und Cloud-Migration – erster Teil

Die anhaltende COVID 19-Pandemie stellt fast alle Branchen vor neue Herausforderungen. Sie hat erhebliche Auswirkungen auf Geschäfts- und Betriebsmodelle. Unternehmen denken darüber nach, wie sie ihr Geschäft für solch große Störungen robuster gestalten können, wie sie schneller Neurungen einbringen und ihren Kunden neue Dienstleistungen anbieten können, wie sie die Gesamtbetriebskosten senken können und wie sie bessere Konnektivität und Zusammenarbeit ermöglichen können. Solche Herausforderungen gab es auch schon vor der Pandemie, aber sie sind jetzt noch relevanter und wichtiger geworden!
Read More