Null : l'erreur à un milliard qui hante encore Java

Conférence QCon London, mars 2009. Un pionnier des langages de programmation, Tony Hoare, fait une déclaration lors d'une présentation publique intitulée "Null References: The Billion Dollar Mistake". Il prend la parole pour s'excuser à propos de son invention du concept de référence nulle, c'est-à-dire de valeur nulle, absente. C’est un concept qui a été repris par la suite par de nombreux langages : 

“I call it my billion-dollar mistake. It was the invention of the null reference in 1965. At that time, I was designing the first comprehensive type system for references in an object-oriented language (ALGOL W). My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.”

En Java, ces erreurs ont un nom : NullPointerException, probablement l’erreur la plus courante. Pour comprendre pourquoi Tony Hoare a fait ce choix de conception et pourquoi cette erreur est si répandue, il faut d'abord connaître quelques concepts plus fondamentaux.

I. D’où vient la référence nulle

Les références, entre Stack et Heap

Le C, ALGOL W, Java, et plein d’autres langages se reposent sur un découpage de la mémoire entre Stack et Heap.

La Stack (la Pile) est une mémoire très efficace conçue pour des données dont la durée de vie est strictement liée à l'exécution d'une fonction. Le mécanisme d’appel d’une fonction à l’autre des langages de programmation fait que la Stack fonctionne en LIFO (Last In First Out) : 

  • A appelle une fonction B
  • l'exécution de la A s'arrête, celle de la B commence
  • lorsque la B est finie, l'exécution de la A reprend
  • Les données de la B sont alors libérées.

Pour persister des données au-delà du périmètre d’une fonction, il faut donc les stocker autrement.

Le Heap (le Tas) est conçu pour ça. C’est un espace commun accessible à toutes les fonctions, avec une longue durée de vie. Pour que les données du Heap soient accessibles, on doit connaître leur adresse exacte dans le Heap. C’est le concept de pointeur, ou de référence.Les pointeurs, ou références, sont le lien indispensable entre deux mondes : ils se situent dans la Stack, et contiennent l’adresse d’une donnée dans le Heap.

Les références font le lien entre stack et heap

Dualité de types en Java

Java, créé en 1995, cherche à répondre à plusieurs critères : performance, robustesse et simplicité de conception. Il reprend cette organisation mémoire, et distingue deux grands types :

  • les références, ou “objets”, pour la robustesse et la simplicité de conception
    • Plusieurs parties du programme peuvent détenir une référence vers le même objet en mémoire. Si l'objet est modifié, toutes les références voient la modification. C'est utile pour les objets à état partagé (comptes bancaires, caches, sessions), mais attention aux effets de bord (voir mon article sur la programmation fonctionnelle).
    • Les objets référencés eux-mêmes ne sont pas déplacés dans la mémoire, c’est leur référence qui est passée d’une fonction à l’autre.
    • Les objets référencés ont un surcoût : une entête (pour retrouver l’objet en mémoire lorsqu’il est appelé, avec les métadonnées…) et un remplissage pour finir sur un multiple de 8 octets
    • Héritage et polymorphisme possibles
  • les primitifs (int, boolean…), pour la performance :
    • Stockage direct dans la Stack, accès quasi instantané
    • Absence de surcharge (“overhead”).
    • Une méthode modifie une copie de notre valeur, pas notre valeur de départ…

En conséquence, on a des comportements différents en fonction du type – on s’est tous faits avoir un jour ou l’autre :

@Test
public void testPrimitif() {
  int x = 2; // 2 est stocké dans la Stack
  add4(x);  // on passe une copie de x

  assertThat(x).isEqualTo(6); // FAUX ! x est toujours égal à 2
}
  
public void add4(int x) {
  x = x + 4;  
}

@Test
public void testReference() {
  var person = new Person(20); // person est la variable dans la stack, qui pointe vers Person(20) dans le Heap
  addOneYear(person); // on passe la référence

  assertThat(person.getAge()).isEqualTo(21); // VRAI ! la référence est maintenue
}
  
public void addOneYear(Person person) {
  person.setAge(person.getAge() + 1);
}

Pourquoi une référence nulle

Pour le contexte, dans les années 60 les langages manipulent des pointeurs : des variables dans la Stack dans lesquelles les développeurs peuvent mettre n’importe quelle adresse mémoire brute du Heap. Exemple en C :

int *p1;    // pointeur créé avec une adresse indéterminée
int *p2 = (int*) 0x12345678;    // pointeur avec une adresse donnée à partir d'un int

int *p3 = malloc(sizeof(int));    // création propre
*p3 = 42;
...
free(p3);   // on libère la mémoire qui peut être réassignée

Résultat : beaucoup de Segfault, qui signalent une adresse mémoire invalide. La troisième méthode libère proprement la mémoire mais p3 pointe toujours dessus, donc toute réutilisation de la variable peut encore provoquer une Segfault.

Avec ALGOL W, Tony Hoare a créé un langage avec des références types sûres, qui cache au développeur ce système de pointeurs :

  • Impossibilité de manipuler directement des adresses mémoire : on utilise le mot clé new pour créer une nouvelle référence. La gestion des adresses mémoire est cachée.
  • Un pointeur ne peut référencer qu’un objet du bon type. Une référence person ne peut pointer que vers un type Person.

Tony Hoare avait ensuite trois options pour contrôler l’initialisation de ces adresses mémoire :

  1. Laisser la référence pointer vers une zone mémoire aléatoire. C'est ce que fait le C.
  2. Interdire les références non initialisées. C’est-à-dire que le compilateur refuse le code tant que la variable n'a pas de valeur. C'est la solution la plus sûre, mais bien plus complexe à implémenter dans un compilateur en 1965. Le compilateur Kotlin par exemple, interdit la nullité pour les variables déclarées sans ?, et Rust ou Haskell n’ont pas de null du tout.
  3. Créer une valeur spéciale, universelle, qui ne pointe vers rien. C'est l’invention du null. Elle est aussi donnée comme valeur par défaut lors de la création de la référence : Int monInt; pour lui donner une valeur sûre temporaire, et éviter les Segfault.

Résultat, aujourd’hui en Java, plus de Segfault mais des NullPointerException omniprésentes, parce que les développeurs oublient que chacun de nos objets est nullable (pour éviter les problèmes de zone mémoire invalide). Ils l’oublient parce que Java ne l’indique pas explicitement.

Aujourd’hui, la nullité en Java :

  • Provoque des NullPointerException qui sont de loin les exceptions les plus courantes.
  • Dégrade la lisibilité, car elle impose des vérifications systématiques parfois imbriquées.
  • Brise la philosophie Java. Java cache toujours la logique des pointeurs (ou référence) aux développeurs sauf dans un cas: le pointeur null.
  • Crée un trou dans le système de types. null ne contient aucune information, ce qui signifie qu’il peut être affecté à n’importe quel type, et quand il est propagé à une autre partie du système, on ne sait plus ce qu’il était censé être initialement.

II. Optional en Java, un outil incomplet

Le problème des null-check

 Se protéger contre les valeurs nulles en Java est laborieux. Prenons une fonction qui récupère le nom de l’assurance d’une voiture à partir de son conducteur. On a donc trois classes : Person, Car, Insurance. On va devoir vérifier d’abord que : 

  • La personne existe
  • qu’elle a bien une voiture
  • et que sa voiture est assurée
class InsuranceService {
  public String getCarInsuranceName(Person person) {
    if (person == null) {
      return null;
    }
    var car = person.getCar();
    if (car == null) {
      return null;
    }
    var insurance = car.getInsurance();
    if (insurance == null) {
      return null;
    }
      return insurance.getName();
  }

  public String getCarInsuranceName2(Person person) {
    if (person != null) {
      if (person.getCar() != null) {
        if (person.getCar().getInsurance() != null) {
          return person.getCar()
                    .getInsurance()
                    .getName();
        }
      }
    }
    return null;
  }
}

Deux propositions classiques qu’on voit trop souvent : 

  • pour la première : 4 points de sortie différents qui complexifient la maintenabilité
  • pour la deuxième une forte imbrication qui dégrade la lisibilité
  • dans les deux cas c’est extrêmement verbeux, et on propage encore la nullité avec return null, qui posera de nouveau des problèmes plus loin dans le code.

Bien sûr, il s’agit ici d’un cas simple, mais dans une application complexe, ces null-checks répétitifs décuplent la quantité de code et cachent les règles métier.

Modélisation de l’absence de valeur

Haskell popularise le type Maybe pour mieux traiter la nullité en 1990. Il définit soit la présence, soit l’absence de valeur.

data Maybe a = Just a | Nothing

En 2004, Scala introduit Option qui encapsule les types (ici T). Option[T] et nous force donc à utiliser les méthodes de l’objet Option, plutôt que celles de T, et donc à tenir compte de la possibilité d’absence de valeur, chose que Java ne faisait pas. 

Option est une classe abstraite qui a deux implémentations :

          Option[T]
              ^
              |
      +-------+------+
      |              |
      |              |
    Some[T]        None[T]

Ensuite, beaucoup d’autres langages reprennent le principe :

  • Rust → Option<T>
  • Swift → Optional
  • Kotlin, Typescript → types nullable ?
  • F# → option

Java se met à la page en 2014 avec Java 8, qui apporte aussi un lot d’outils de programmation fonctionnelle, et nous force à repenser notre manière de coder et de traiter les valeurs optionnelles.

@Getter
class Insurance {
  private Optional<String> name;
}

@Getter
class Car {
  private Optional<Insurance> insurance;
}

@Getter
class Person {
  private Optional<Car> car;
}

public class InsuranceService {
  public String getCarInsuranceName(Person person) {
    return Optional.ofNullable(person)
          .flatMap(Person::getCar)
          .flatMap(Car::getInsurance)
          .flatMap(Insurance::getName)
          .orElse("Unknown");
    }
}

Variables et méthodes sont plus explicites ; on sait directement si le champ peut être absent ou non. On doit déplier l’Optional pour utiliser la valeur qu’il encapsule. Plus de NPE possible. De plus, le code exprime l’intention métier : “de la personne je veux la voiture, de la voiture l’assurance, de l’assurance le nom, et sinon Unknown”. C’est l’avantage du langage fonctionnel, plutôt qu’une série de vérifications impératives

Les limites d’Optional

Dans notre exemple au dessus, la plupart des IDE vous mettront un warning sur les attributs de classe de type Optional. Le problème est qu’en Java, Optional est un compromis entre : 

  • Une nouvelle gestion du nullable, plus moderne et safe
  • Le legacy, les bibliothèques et frameworks utilisés en masse qui ne gèrent pas Optional

Pour ne pas rompre des décennies de frameworks et de librairies, il a été pensé uniquement comme valeur de retour de méthode, et pas en remplacement complet.

public Optional<User> findUserById(String id); // OK

public Optional<Car> getCar() {
  return Optional.ofNullable(car);   // OK
}

class Person {
  Optional<Car> car;   // déconseillé
}

Si on peut trouver intuitif le fait qu’une personne puisse avoir une voiture ou non, en Java, une classe est conçue pour représenter seulement un état permanent. Les développeurs n’ont donc pas fait en sorte qu’Optional implémente Serializable, par choix de design, et pour décourager cette utilisation. Résultat : toute classe susceptible d’être stockée en binaire ne peut pas contenir de champ Optional : sauvegarde sur un disque, envoi sur un réseau, etc…

Par souci d’uniformité, c’est donc recommandé d’éviter les Optional en attribut de classe de manière générale. Mais alors on se retrouve à faire beaucoup d’aller-retours dans les types :

person2.setCar(Optional.ofNullable(person1.getCar()) // je dois transformer en Optional à chaque utilisation du champ
.map(car -> doSomething(car))
.orElse(new Car()));

var carOpt = carService.findCarByPersonName(personName); // retourne Optional
person3.setCar(carOpt.orElse(null)); // je dois repasser en nullable à chaque fois

Dans une architecture hexagonale, une même valeur traverse souvent plusieurs couches (contrôleur, application, domaine, infrastructure). Comme les POJO de chaque couche ne peuvent pas contenir Optional, une simple valeur optionnelle peut être convertie plusieurs fois avant d’atteindre sa destination, ce qui génère énormément de boilerplate et dégrade la lisibilité.

Optional ne peut pas être propagé d'une couche à l'autre

Optional ne peut pas être propagé naturellement dans nos programmes. C’est un vrai frein à son utilisation.

L’autre problème de ce choix de design, c’est qu’on n'a pas de manière uniforme de traiter la nullité. Si on gérait tous les champs optionnels avec Optional, ce serait simple : tout champ qui n’est pas en Optional dans notre code serait par déduction forcément présent. Donc plus besoin de null-check. Mais si Optional n’est utilisé que dans certains cas, c’est un vrai casse-tête. Si je vois une variable Car car, je ne sais plus si : 

  • Il n’est pas Optional parce qu’il est toujours présent
  • Il n’est pas Optional parce que c’est un attribut de classe, ou provient d’une librairie ou framework qui ne supporte pas Optional

On perd tout l’avantage d’Optional. La gestion du nullable dans le code devient un vrai champ de bataille : parfois Optional, parfois non, parfois plusieurs null-check sur le même champ dans plusieurs parties de l’application, et un code assez peu lisible. C’est le grand retour des NPE dans nos applications.

III. Quelques solutions

Retourner Optional des getters

Lors de la sérialisation d’une classe, seuls les attributs sont sérialisés, pas les méthodes. C’est parce qu’elle enregistre uniquement l’état d’un objet, pas son comportement. On peut donc transformer le champ nullable en Optional dans les getters :

class Person {
  private Car car;

  public Optional<Car> getCar() {
    return Optional.ofNullable(car);
  }
}

Ce qui résout le problème des champs optionnels dans nos classes. Par contre : 

  • Ça oblige les développeurs à être très rigoureux sur l’écriture des getters, et ajoute du boilerplate.
  • On a une légère incohérence, entre le setter void setCar(Car car) qui prend un champ nullable, et le Optional<Car> getCar() qui renvoie un Optional.
  • C’est valable uniquement depuis l'extérieur de la classe ; en interne, le champ reste nullable.
  • Les frameworks de sérialisation (Jackson) ou les ORM (JPA...) accèdent souvent aux champs par réflexion, en contournant complètement le getter. Ils voient donc directement le champ nullable.

@Nullable, une promesse fragile

Une autre approche consiste à utiliser des annotations de documentation comme @NonNullet @Nullable. Elles sont comprises par de nombreux IDE. IntelliJ signale une erreur si on appelle une méthode sur une référence annotée @Nullable sans avoir vérifié sa présence : Passing ‘null’ argument to parameter annotated as @NonNull.

JSpecify est un effort de standardisation des nombreuses annotations existantes, et elle apporte en plus @NullMarked, qui rend la non-nullité le comportement par défaut, et oblige donc à annoter de @Nullable les autres. Spring Boot a migré vers ce standard fin 2025 avec Spring Boot 4, un signe que l'écosystème Java converge enfin vers une solution unifiée.

@NullMarked
class Person {
    private @Nullable Car car;

    public @Nullable Car getCar() {
        return car;
    }
}

Ces annotations n’introduisent aucun boilerplate et restent compatibles avec l’ensemble de l’écosystème Java. Mais elles ont des défauts : 

  • Les annotations ne sont pas vérifiées par le compilateur et reposent entièrement sur les outils utilisés (sauf si on tire la dépendance “NullAway”, qui n’est pas fournie nativement par JSpecify)
void setCar(@NonNull Car car) {
...
}
setCar(null); // ça passe

  • Elles nécessitent d’annoter l’ensemble du code : attributs, paramètres de méthodes, valeurs de retour... Un seul oubli casse la chaîne d’information, contrairement à Optional.

Vavr, une solution élégante et complète

Une autre solution consiste à utiliser une bibliothèque externe comme Vavr, qui propose une approche plus proche de Scala ou Haskell en apportant des types fonctionnels à Java.

Vavr, c'est Java écrit à l'envers

Son équivalent d’Optional s’appelle Option, et il est parfaitement sérialisable ! Il peut donc être utilisé comme attribut de classe. Ça change tout, parce que les champs optionnels peuvent enfin être propagés de manière uniforme du début à la fin d’un programme. Plus besoin d’Optional.ofNullable(car) ou de .orElse(null), et chaque champ qui n’est pas Option est par déduction toujours présent.

Option de Vavr a d’autres avantages :

var carOpt = Option.of(car) 
// Option n'a pas de méthode .ofNullable(); c'est .of() qui gère
// les deux cas, valeur présente (Some()) ou absente (None()).
// Option.of(null) renvoie None(), Optional.of(null) renvoie une NPE


// Peek, pour faire une action en conservant l'option
Option<String> getUserProfile(String email) {
  return findUser(email)
    .peek(user -> logger.info("User found : {}", user.getId()))
    .map(User::getProfile)

// En Java pur, on doit utiliser map et faire un return, en interrompant la chaîne d'appel
public Optional<String> getUserProfile(String email) {
  return findUser(email)
    .map(user -> {
      logger.info("User found : {}", user.getId());
      return user;
    })
    .map(User::getProfile)


// fold permet de fournir deux chemins, pour Some() ou None()
String result = findOrder(orderId)
  .filter(o -> o.getStatus() != CANCELLED)
  .map(pricingService::computePrice) // Option<BigDecimal>
  .fold(
    () -> handleMissingOrder(orderId),   // Si None()
    price -> processValidOrder(orderId, price) // Si Some()
  );

Vavr est plus large que ça ; c’est une surcouche de Java qui lui apporte des outils de programmation fonctionnelle

Résolution du problème à la source avec Kotlin

Toutes les solutions présentées jusqu’ici cherchent à améliorer la gestion de la nullité sans vraiment traiter le problème à la source. Kotlin fait un choix différent : la nullité fait partie intégrante de son système de types. Kotlin s'exécute sur la JVM, et s’intègre très bien à Spring Boot.

Une référence Kotlin est, par défaut, non nullable, sauf si annotée ?, auquel cas le compilateur kotlin refusera toute utilisation de la valeur sans null-check.

Kotlin propage le null sans NPE. Il est aussi beaucoup plus concis que Java :

fun getCarInsuranceName(person: Person?): String {
  return person?.car?.insurance?.name ?: "Unknown" // si person.car est null, aucune NPE, la méthode retourne “Unknown”
}

La bibliothèque Arrow.kt propose aussi des outils de développement fonctionnel : Option, Either, Validated… à l’image de ce que fait Vavr pour Java.

Kotlin apporte de nombreuses autres fonctionnalités inspirées des langages fonctionnels : fonctions d’extension, immutabilité, coroutines, inférence de types… tout en restant entièrement compatible avec la JVM. Une application Kotlin peut utiliser des bibliothèques ou frameworks Java sans difficulté, comme Spring Boot.

Sa limite : la null-safety de Kotlin ne se propage pas aux bibliothèques Java. Donc lorsque Kotlin appelle du code Java, il ne sait pas si la valeur retournée est nullable ou non. C’est donc au développeur de penser à sécuriser l’appel, avec ? par exemple.

userRepository.findByName("Alice")?.let { user ->
  println("Bonjour ${user.name}")
}

Conclusion

Est-ce que ce choix de conception a coûté un milliard de dollars ? Difficile à dire,  mais aujourd'hui il pose de vrais problèmes parce que Java n'a jamais complètement intégré l’absence de valeur à son système de types.

Optional a comblé une partie du vide, mais sans jamais devenir la norme. Quelques solutions d’annotations peuvent aider les développeurs à mieux gérer les valeurs optionnelles, mais il faut se tourner vers une bibliothèque externe comme Vavr pour avoir une solution complète.

Il aura fallu attendre des langages plus récents comme Kotlin pour voir la nullité traitée comme une propriété du type lui-même, plutôt que comme une exception à gérer au cas par cas. Là où Java ajoute un outil, Kotlin change les règles. Aujourd’hui, Kotlin se veut une alternative moderne et null-safe du langage Java.

Le projet Valhalla d’OpenJDK tente de rattraper le retard avec une syntaxe qui rappelle celle de Kotlin : ! pour une référence non-nullable, ? pour nullable. Mais il est encore expérimental et les types null-restricted ne sont pas attendus avant plusieurs versions de Java. En attendant, c'est Kotlin qui, aujourd'hui, incarne cette philosophie.