arrow arrow--cut calendar callback check chevron chevron--large cross cross--large download filter kununu linkedin magnifier mail marker media-audio media-blog media-video menu minus Flieger phone play plus quote share youtube

Nullability in Java – A New Hope?

Seit dem Erscheinen von Spring Boot 4 sprechen alle über JSpecify. Ich möchte das auch! Jedoch möchte ich weniger die Syntax erklären, sondern mich mit der grundsätzlichen Bedeutung beschäftigen.

Doch worum geht es bei dem Thema eigentlich?

Tony Hoare nannte Null-Referenzen einst seinen “Billion Dollar Mistake”. Nur ist es nicht so einfach: Wir müssen in der Lage sein, in der Software undefinierte Zustände abzubilden. Insbesondere – während Objekte konstruiert werden – gibt es Schwebezustände, die sich nicht vermeiden lassen. Manchmal sind auch “leere” Rückgaben valide. Das Problem ist in meinen Augen nicht die Tatsache, dass es null gibt, sondern die Tatsache, dass nicht klar ist, wann ein null zu erwarten ist.

Foto von Jochen Wierum
Jochen Wierum

Software Developer

Es gibt diverse Wege, um damit umzugehen. java.util.Optional ist in Java ein Weg, um explizit mit der Abwesenheit eines Wertes umzugehen. Optional eignet sich hervorragend als Rückgabewert: Hier wird der aufrufende Code dazu gezwungen, sich aktiv mit der Rückgabe auseinanderzusetzen. Andere Programmiersprachen gehen weiter: In TypeScript muss null als Datentyp explizit erlaubt werden.

Aber auch in der Java Virtual Machine (JVM) gibt es Beispiele, wie null besser behandelt werden kann. Kotlin ist hier wohl das bekannteste: 

fun randomName(): String? =
    "test".takeIf { Math.random() < 0.5 }

fun main() {
    // String?: String or null
    val value: String? = randomName()

    // "test" or "null"
    println(value)

    // will not even compile!
    // in java, a NullPointerException could be thrown
   println(name.length)

    // 4 or null
    println(value?.length)

    // 4 or 0
    println(value?.length ?: 0)

    if (value != null) {
        // SmartCast: value is now of type 'String'
        // will now compile!
        println(value.length)
     }
}

Obwohl die JVM keinen expliziten Support im Typsystem bietet, geht Kotlin mit null besonders um: Kotlin löst dieses Problem einfach schon im Compiler. Zur Laufzeit ist auch hier alles ein Object – wie in Java. Doch da es keinen Weg am Compiler vorbei gibt, ist die Typsicherheit hier gegeben – zumindest für vertrauenswürdige Sourcen.

Wäre es nicht schön, dies auch in Java zu haben? 

JSpecify

Es wurde mehrfach versucht, diesem Problem zu begegnen. JetBrains stellt zum Beispiel Annotationen zur Verfügung, um potenzielle Null-Werte zu kennzeichnen und prüft in der IDE deren Einhaltung. Auch PMD versucht, solche Fälle zu finden. Und auch in Java wurde in JSR 305 bereits 2006 über etwas ähnliches nachgedacht. Und wie so oft, wenn es zu viele Standards gibt, ist es Zeit für einen neuen: JSpecify

Quelle: https://xkcd.com/927/

JSpecify ist ein weiterer Versuch, das Problem in den Griff zu bekommen. Die Version 1.0 wurde nach zwei Jahren Arbeit im Sommer 2024 veröffentlicht. Für mich hat das Projekt drei entscheidende Vorteile gegenüber seinen Vorgängern: 

  1. Erstens ist es kein Alleingang, sondern ein Zusammenschluss vieler namhafter Projekte.
  2. Zweitens besteht es nicht nur aus vier Annotationen, sondern aus einer ausführlichen Spezifikation, wie Null-Safety zu prüfen ist.
  3. Drittens gibt es mit Spring jetzt auch ein prominentes Framework, das JSpecify bekannt macht.

Und was macht JSpecify besonders?

  • Sämtliche Prüfungen finden zur Compile-Zeit statt. Die Performance der Anwendung ändert sich dadurch nicht.
  • Nullchecks werden – wie in Kotlin oben – erzwungen und unnötige Nullchecks werden angekreidet. Damit sind sie nicht mehr spekulativ, sondern haben eine Bedeutung. Der Code wird damit verständlicher.
  • JSpecify arbeitet mit Annotationen. Es lässt sich problemlos schrittweise einführen und auch bewusst mit bestehenden Alternativen wie java.util.Optional mischen. Letzteres hat nämlich nach wie vor seine Berechtigung! Selbstverständlich sollten Werkzeuge wie Jakarta Validation weiterhin für die Validierung von Eingaben verwendet werden.

Es ist jedoch nicht die berühmte Silver Bullet: Die Sicherheit wird zur Compile-Zeit geschaffen und sie kann z. B. mittels Reflection umgangen werden. Ich bin aber dennoch davon überzeugt, dass sie bei verantwortungsvoller Nutzung einen Mehrwert schaffen.
 

In der Praxis

Erste Experimente versprechen sofort Erfolge: Schon das Hinzufügen der Abhängigkeit und einiger Annotationen genügt, damit IntelliJ Warnungen erzeugt:

Auch die Annotationen sind denkbar einfach: Entweder werden Klassen oder ganze Packages mit @NullMarked annotiert und Elemente, die null werden können, werden mit @Nullable markiert, oder es wird @NullUnmarked mit @NonNull verwendet. Dann funktioniert es andersherum und es wird nur das markiert, was kein null zurückgibt.

Der Support in der IDE reicht aber nicht aus! Schließlich können Warnungen nicht gesehen oder ignoriert werden. Wichtig ist, was in der CI/CD-Pipeline passiert. Und hier wäre natürlich wünschenswert, wenn der Java-Compiler die nötigen Prüfungen übernehmen würde. Leider tut er dies nicht. Dann vielleicht mit einem Compiler-Plugin? Leider gibt es auch dieses nur indirekt.

Der offizielle Weg, den Source-Code zu überprüfen, ist über das NullAway-Plugin für den Googles ErrorProne-Compiler-Plugin. Da ErrorProne aber deutlich mehr prüft, müssen diese Prüfungen gegebenenfalls erst deaktiviert werden. Problemlos funktioniert dies erst ab Java 25, was aber zusätzliche Exporte benötigt. Das Minimalbeispiel in Maven ist alles andere als minimal:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <version>3.14.1</version>
  <configuration>
    <release>25</release>
    <fork>true</fork>
    <compilerArgs>
      <arg>-XDcompilePolicy=simple</arg>
      <arg>--should-stop=ifError=FLOW</arg>
      <arg>-Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR-XepOpt:NullAway:OnlyNullMarked
-XepOpt:NullAway:JSpecifyMode=true</arg>
      <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
      <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
      <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
      <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
      <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
      <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
       <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
       <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
         <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
        <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
      </compilerArgs>
      <annotationProcessorPaths>
      <path>
        <groupId>com.google.errorprone</groupId>
        <artifactId>error_prone_core</artifactId>
        <version>2.42.0</version>
      </path>
      <path>
        <groupId>com.uber.nullaway</groupId>
        <artifactId>nullaway</artifactId>
        <version>0.12.12</version>
      </path>
    </annotationProcessorPaths>
  </configuration>
</plugin>

Das Setup für gradle ist deutlich schlanker.

Nun schlägt der Compiler Alarm, falls Nullchecks fehlen:

[ERROR] COMPILATION ERROR :
[ERROR] /home/jwierum/jspecify/src/main/java/Main3.java:[17,27] Fehler: [NullAway] dereferenced expression new NameGenerator().generateName() is @Nullable
    (see http://t.uber.com/nullaway )

Damit sind wir unserem Ziel ein großes Stück näher. Sicherlich ist das Setup nicht schön, aber immerhin ist es nur einmal nötig. Und wer weiß – vielleicht werden zukünftige Versionen das Setup ja weiter vereinfachen.

Für wen?

Für wen ist JSpecify nun interessant? In erster Linie wünsche ich mir eine große Verbreitung in genutzten Libraries. Es ist eine große Erleichterung, nicht in die Dokumentation oder den Quelltext schauen zu müssen, um Rückgaben richtig zu interpretieren.

Aber auch bei eigener Software mit hinreichender Komplexität verspreche ich mir von den Annotationen viel! Denn die mentale Last beim Lesen von Quelltext und dem Verfolgen von Daten durch einzelne Methoden wird auch hier reduziert.

Und auch KI kann von den zusätzlichen Informationen beim Generieren und Refactorn von Quelltext profitieren (Es ist 2025, natürlich muss “KI” erwähnt werden ;)).

Mein einführendes Beispiel hat Kotlin gelobt. Und auch hier gibt es gute Nachrichten: Der Kotlin-Compiler versteht den annotierten Java-Code ebenfalls. So wird aus den unschönen Plattform-Typen “String!” nun “String” oder eben “String?”. Großartig!

Bei kleinen Projekten mit niedriger Komplexität wiegen die Vorteile sicherlich weniger. Ich kann mir aber kein Szenario vorstellen, indem die Nutzung von JSpecify ein Problem verursachen würde.

Und was ist mit Legacy-Projekten? Auch hier ist eine schrittweise Einführung möglich! Es können nach und nach einzelne Klassen oder Packages umgestellt werden. Das ermöglicht es, Projekte auch nur partiell zu migrieren. Begonnen werden kann dann dort, wo es am einfachsten ist oder wo der Nutzen am größten ist.

A New Hope?

Doch warum titelte ich den Blog mit “A New Hope”, noch dazu, wo ich eigentlich Trekkie bin?

Ich bin einfach davon überzeugt, dass dies nur der Anfang sein kann. Das Einbauen in die Build-Pipeline ist komplex. Und obwohl die Annotationen eine elegante Lösung sind, weil sie nichts an der Syntax der Sprache ändern, reichen mir die Möglichkeiten noch nicht aus.

Nehmen wir das folgende Beispiel:

@NullMarked
class StringUtils {
    // @Contract("null -> null, !null -> !null")
    @Nullable String toUpperCase(@Nullable String s) {
        return s == null ? null : s.toUpperCase();
    }
}

Ist dem Aufrufer bekannt, dass der übergebene String nie null sein kann, erzwingt JSpecify dennoch die Prüfung der Rückgabe. Die (auskommentierte) Contract-Annotation von JetBrains würde es hier erlauben, eine Aussage darüber zu treffen, wann die Rückgabe null ist. So etwas wünsche ich mir für JSpecify auch!

Dennoch: Bereits jetzt empfinde ich JSpecify als klaren Mehrwert im Java-Ökosystem.

Ausblick

Ob JSpecify irgendwann Teil des Java-Compilers wird, ist allerdings fraglich. Denn in Project Valhalla wird ebenfalls fleißig daran gearbeitet, den Umgang mit null zu erleichtern. Es wird aber noch einige Jahre dauern, bis dies für uns nutzbar wird. Außerdem bin ich mir sicher, dass auch Oracle aus Projekten wie JSpecify lernen wird. So ist das Einführen von Annotationen eine gute Lösung, denn so hinterfragen wir unseren eigenen Code. Wenn wir aus “@Nullable String getName()” in drei Jahren ein “String! getName()” machen müssen, ist dies dann vielleicht nur noch eine “Suchen und Ersetzen”-Operation weit entfernt :)

Ich hoffe, dass ich mit dem Artikel ein paar Denkanstöße platzieren und meine Gedanken erklären konnte.

Bis bald!

java.lang.NullPointerException: Cannot invoke "Author.name()" because "author" is null