Jérémy Le Piolet - Blog

Android : éviter les NullPointerException

Cet article a été écrit il y a plus d'un an : son contenu peut être dépassé.

C’est la bête noire de toute personne faisant du java (et a fortiori de l’Android) : la NullPointerException. D’où vient-elle ? Quel est son but ? Comment en venir à bout ? Tout ce que vous avez toujours voulu savoir sans jamais oser le demander.

Origine

La NullPointerException arrive lorsque l’on essaye d’appeler une méthode sur un objet null, c’est à dire non initialisé. Par exemple, ce code génère une NullPointerException :

String maString = null;
maString.contains("test"); //Produit une NullPointerException

Bien évidemment, en pratique, ce n’est pas aussi simple que cela. L’objet null peut transiter par plusieurs méthodes avant d’être découvert et il peut parfois être compliqué d’identifier les éléments qui n’ont pas été correctement initialisés.

Par exemple, la recherche d’un élément dans une HashMap peut générer une NullPointerException :

HashMap<String,String> maHashMap = new HashMap<>();
maHashMap.put("id1", "test"); //Ajout d'un élément dans la Hashmap

String maString = maHashMap.get("id2"); //Récupération d'un objet qui n'existe pas dans la Hashmap => null
maString.contains("test"); //Génère NullPointerException car maString est null

Dans tous les cas, la réponse du système est toujours la même : crash de l’application. Il convient donc d’éviter ce genre d’erreurs sous peine de voir surgir une foule d’utilisateurs mécontents !

S’en prémunir

Il existe plusieurs solutions pour s’en prémunir. Certaines sont génériques, d’autres vont nécessiter des versions spécifiques d’Android. En voici néanmoins une petite liste.

Tester l’objet

La solution la plus simple pour régler le problème est de tester tous les objets avant d’appeler toutes les méthodes. Par exemple, dans notre cas :

String maString = null; 

if(maString != null){
maString.contains("test"); //ne produit pas une NullPointerException car si on arrive ici, c'est que maString n'est pas null
}

Mais la méthode n’est pas optimale. Non seulement elle alourdit le code, mais elle ne règle pas le problème de fond qui est la mauvaise initialisation d’une variable. Et un oubli étant vite arrivé, il est difficile de garantir que tous les tests soient effectués systématiquement. D’où l’importance de résoudre le problème différemment quand c’est possible.

Vérifier la présence des objets

A peu près toutes les classes du SDK qui sont susceptibles de retourner des objets null proposent des méthodes pour vérifier la présence d’objets.

Par exemple, lorsque vous sauvegardez des paramètres dans un Bundle, vous pouvez (et devez) tester la présence de l’objet avant d’y accéder.

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

     String maString = ""; //Initialisation par défaut à vide
     if(savedInstanceState != null){ //savedInstanceState peut-être null
          if(savedInstanceState.contains(KEY)){
                //Récupération de l'objet précédemment sauvegardé
                maString = savedInstanceState.getString(KEY);
          }
     }
}

Vous vous assurez ainsi de ne jamais récupérer un objet null. C’est valable aussi pour les List,  les itérateurs, les SharedPreferences, etc.

Utiliser des valeurs par défaut différentes de null

Cela peut sembler bête mais trop souvent, null est utilisé comme valeur par défaut, faute de mieux. Si ce n’est pas forcément idiot de mettre une valeur indépendante du type de l’objet que l’on souhaite manipuler, chaque utilisation introduit un risque supplémentaire de voir l’exception se produire.

Quand c’est possible, il vaut mieux essayer de privilégier une valeur neutre. Dans le cas d’une String par exemple, on peut mettre une chaîne vide. Généralement, une chaîne vide, c’est signe qu’il n’y a rien à déclarer, circulez, y a rien à voir.

Dès lors, pour tester l’existence ou non de la chaîne, il suffit de tester si la chaîne est vide au lieu de tester l’égalité à null. Ainsi, si on oublie de faire une vérification dans la suite du code source, un traitement sur une chaîne vide fait rarement de dégâts (au pire, un espace blanc affiché sur une interface, ce qui est moins préjudiciable qu’un crash).

String maString = ""; 
maString.contains("test"); //Retourne un résultat conforme 

if(!maString.isEmpty()){ //remplace le (maString != null) 
  //Traitement à effectuer si ce n'est pas une valeur vide 
}

N’hésitez d’ailleurs pas à vérifier que les objets que vous utilisez ne possède pas une valeur par défaut disponible. Par exemple, la classe Collections propose des valeurs pour indiquer des listes/map/set vides :

List maListe = Collections.EMPTY_LIST;

Outre le fait de ne pas initialiser à null, cette méthode à l’avantage de ne pas créer de nouvelle instance de liste (et donc de ne pas surcharger inutilement la mémoire, même si l’impact reste faible). Attention toutefois : EMPTY_LIST est immuable, il n’est pas possible de modifier l’élément ; il sera donc nécessaire de créer un objet liste quand on voudra utiliser une vraie liste. À n’utiliser que lorsque la liste sera consultée en lecture uniquement (retour de chargement d’un Loader, par exemple).

Il est également possible, quand vous récupérez certains éléments, de donner une valeur par défaut si l’élément n’existe pas. Pour reprendre l’exemple du Bundle, il est possible de faire ceci :

@Override 
protected void onCreate(Bundle savedInstanceState) { 
    super.onCreate(savedInstanceState); 

    String maString = ""; //Initialisation par défaut à vide 
    if(savedInstanceState != null){ //savedInstanceState peut-être null 
        maString = savedInstanceState.getString(KEY, ""); //Si Key n'existe pas, la fonction retournera une chaîne vide
    }
}

Utiliser des objets vides

Toujours dans l’objectif de passer une valeur neutre alors qu’il n’y a pas de valeur « neutre » possible autre que null, il est possible d’appliquer le Null Object Pattern (ou patron de l’objet null, en bon français).

En quoi ça consiste ? On crée tout simplement un objet vide que l’on renverra à la place de null quand on aura besoin de renvoyer une valeur neutre. Supposons le code suivant :

MonObjet instanceDeMonObjet = null;
instanceDeMonObjet.maMethode(); //NullPointerException<

Pas de mystère, on a du null à l’initialisation donc génération de NullPointerException par la suite.

Maintenant, on va créer une classe abstraite (ou une interface) d’où vont découler deux implémentations : une concrète qui fait des choses et une vide, qui ne fait rien :

abstract class MonObjet {
    abstract void maMethode();
}

class MonObjetImplementation {
    void maMethode(){
        //code
        //code
        //...
    }
}

class MonObjetVide {
    void maMethode(){
        //on ne fait rien
    }
}

Le code source précédent devient alors :

MonObjet instanceDeMonObjet = new MonObjetVide() ;
instanceDeMonObjet.maMethode(); //Il ne se passe rien

Et là, magie, pas de NullPointerException. On peut très bien transmettre cet objet dans toute l’application, il ne causera pas d’erreur puisqu’il existe et implémente toutes les méthodes. Génial, non ?

Eh bien pas si génial que ça en fait. En pratique, l’objet qui ne plante jamais va également masquer les erreurs et les problèmes. Avec le risque de tomber dans des états bizarres avec des objets vides, voire de carrément peupler toute l’application avec des objets vides.

Ce qui, in fine, sera préjudiciable : en ne détectant plus les erreurs (ou moins rapidement), on occulte des problèmes parfois importants, notamment conceptuels ou architecturaux.

Personnellement, je recommande d’utiliser ce pattern avec parcimonie (voire pas du tout). C’est un pattern qui est adapté lorsque les applications brassent un nombre important de données et pour lesquelles le nombre de contrôles serait extrêmement lourd. C’est rarement le cas pour une application Android qui ne gère qu’un petit lot de données : le pattern apportera plus de contraintes que de bénéfices (beaucoup de code supplémentaire, risque de confusion entre implémentations concrètes et vides, etc.)

Utiliser des Optionnal

Petite nouveauté introduite dans Java 8, les Optionnal sont une solution intéressante, à mi-chemin entre l’initialisation vide et le Null Object Pattern.

Le principe est simple : au lieu de retourner directement l’objet, on retourne un contenant (l’Optionnal) que l’on pourra tester pour savoir s’il contient un objet ou pas. Si oui, on peut récupérer l’objet depuis l’Optionnal, si non, l’Optionnal nous indique qu’il n’y a rien.

L’avantage, c’est que l’on ne récupère pas directement l’objet : il faut obligatoirement passer par la récupération de l’Optionnal et si l’on tente de passer outre les tests, on récupère une exception. Cela évite aussi, dans les cas d’objets lourds, de les initialiser avec des valeurs vides alors que c’est inutile.

//Fonction qui retourne un optionnal
Optionnal&lt;monObjet> getMonObjet(){
   // Traitement qui retourne un objet

   return Optionnal.ofNullable(monObjetARetourner); //monObjetARetourner peut exister ou être null
}

//Utilisation de l'optionnal
Optionnal&lt;MonObjet> optionnal = getMonObjet();

//Test de la presence ou pas d'un objet
if(optionnal.isPresent()){
    // Il y a un objet => traitement pour le cas normal
    MonObjet instanceDeMonObjet = optionnal.get();
}else{
    // Il n'y a pas d'objet => traitement pour le cas vide
}

Seul problème : la classe Optionnal n’est pas disponible avant la version 24 (Nougat) du SDK. À l’heure actuelle, le nombre de terminaux sous cette version est trop limité pour s’y restreindre. Certaines librairies proposent néanmoins leur propre implémentation : c’est notamment le cas de Guava ou StreamSupport.

Si vous ne souhaitez pas intégrer une librairie externe dans votre projet (inutile de surcharger bêtement pour si peu), vous pouvez également développer votre propre implémentation d’Optionnal. Vous couvrez ainsi le même besoin et le jour où vous augmenter votre SDK, vous n’avez plus qu’à modifier les imports sans toucher au reste du code.

Voici un exemple simple d’implémentation pour comprendre le principe général ; il est bien entendu possible de l’améliorer fortement :

public class Optionnal&lt;T> {

    //L'obejt stocké dans l'optionnal
    private T mObject;

    private Optionnal(T value){
        mObject = value;
    }

    //Retourne un Optionnal
    public static&lt;T> Optionnal&lt;T> ofNullable (T value) {
        return new Optionnal(value);
    }

    //Test si l'optionnal contient une valeur ou non.
    public boolean isPresent (){
        return (mObject != null);
    }

    // Récupération de l'objet de l'optionnal.
    // On génère une exception si l'objet est null, pour forcer la vérification via isPresent avant et éviter les accès direct.
    public T get() throws NoSuchElementException{
        if(mObject == null) throw new NoSuchElementException("no value in Optionnal");
        
        return mObject;
    }

}

Vous pouvez également vous inspirer du code de la classe Optionnal de l’openJDK ou de celui de Guava.

Limiter les enchaînements pointés

Une tendance assez forte chez certains développeurs est la sur-utilisation des enchaînements pointés. Par exemple :

@Override 
protected void onCreate(Bundle savedInstanceState) { 
    super.onCreate(savedInstanceState); 
    

    if(savedInstanceState.getString(KEY).contains("test")){
        //Traitement à déclencher si la chaîne contient test 
        //code
    } 
}

Le problème, c’est que l’on considère ici que tout est initialisé et correct. Or, savedInstanceState peut être null, de même que getString peut retourner une valeur null. On a donc deux risques potentiels de voir se déclencher une NullPointerException.

Donc, à moins d’être complètement certains de vos objets, évitez. Dans l’exemple, cela peut se corriger comme ceci :

@Override 
protected void onCreate(Bundle savedInstanceState) { 
    super.onCreate(savedInstanceState); 

    if(savedInstanceState != null){ //savedInstanceState peut-être null 
        String maString = savedInstanceState.getString(KEY,""); //Si Key n'existe pas, la fonction retournera une chaîne vide

        if(maString.contains("test")){ 
            //Traitement à déclencher si la chaîne contient test 
            //code
        }
    }
}

Ici, savedInstanceState a été testé avant d’y accéder, de même que getString retournera forcément, dans le pire des cas, une chaîne vide qui ne fera pas planter le test. C’est un peu plus verbeux (il est aussi possible de conserver le pointage une fois savedInstanceState testé) mais c’est plus sécurisé.

Modifier l’ordre des appels

Petite astuce toute bête mais qui peut faire des miracles : changer l’ordre des appels et des paramètres.

Par exemple, prenons ce test de chaîne de caractères :

maString.equals("test")

Il y a trois réponses possibles :

  • true si maString est la chaîne de caractères « test »
  • false si maString n’est pas la chaîne de caractères « test »
  • NullPointerException si maString est null (oui, il y a aussi ce cas)

Réécrivons maintenant notre test :

"test".equals(maString)

Il n’y a maintenant que deux réponses possibles :

  • true si maString est la chaîne de caractères « test »
  • false si maString n’est pas la chaîne de caractères « test »

Si maString est null, la réponse sera false puisque l’objet « test » existe forcément (c’est une constante) : on évite ici le risque de NullPointerException par une simple inversion.

Ceci peut bien entendu être généralisé à n’importe quel objet, dès l’instant que l’un d’eux est un objet constant et initialisé et que la méthode equals associée traite correctement les objets null.

Utiliser les annotations @NonNull et @Nullable

Récemment, la JSR 308 a ajouté au langage Java les annotations @NonNull et @Nullable. Ces annotations sont intégrées dans android studio et peuvent être utilisées partout.

Les deux annotations ont les rôles suivants :

  • @NonNull : indique qu’un paramètre ne peut être null. Le compilateur signale toute valeur null passée en paramètre d’une fonction avec valeur marquée comme @NonNull
@NonNull  //Indique que la fonction ne retourne jamais null
public String maFonction(@NonNull Object monObject){ // le paramètre ne doit pas être null
   //code
}
  • @Nullable : indique qu’un paramètre peut être null. Le compilateur signale que la valeur peut être null dès que vous utilisez l’objet sans vérifier s’il peut être null ou non
@Nullable  //Indique que la fonction peut retourner un objet null
public String maFonction(@Nullable Object monObject){ // le paramètre peut être null
   //code
}

Attention : comme toute annotation (par exemple @Override) , elles permettent au compilateur de signaler des erreurs potentielles lorsqu’il scanne le code. Néanmoins, selon la configuration de celui-ci, la présence d’erreurs liées à ces annotations n’empêchera pas la compilation. Une application ne respectant pas les règles peut très bien compiler quand même !

C’est notamment le cas avec Android Studio qui traite ces annotations comme de simples warnings : il est possible de passer outre et compiler quand même. Une bonne pratique est de changer le niveau de sévérité (mais gardez à l’esprit que si vous, vous le faites, vos collègues ne le font pas forcément).

Configuration de Lint via Android Studio et Gradle

Lancer des NullPointerException

Dernière solution : lancer des NullPointerException ! Oui oui, vous avez bien lu, pour éviter les NullPointerException, lançons des NullPointerException !

Prenons par exemple un constructeur simple que j’ai développé :

public MonObjet(String monParametre){
    //Du code
}

Si je ne précise rien, rien n’interdit à un de mes collègues qui utilise ma classe de faire cette initialisation :

MonObjet toto = new MonObjet(null);

Il introduit ici l’objet null comme paramètre du constructeur. Et peut-être que moi, ce paramètre dans le constructeur, je le stocke comme attribut et je m’en sers par la suite. Et là, bim, NullPointerException un peu plus tard quand j’y accède sans faire de contrôle.

Alors bien évidemment, je peux faire évoluer la déclaration du constructeur pour l’annoter avec @NonNull. Mais ça n’empêchera pas mon collègue de compiler parce que le réglage des annotations sur son IDE est sur warning et qu’il n’est pas très rigoureux.

Je peux également tester la valeur et mettre une valeur par défaut si on me passe un objet null. Satisfaisant.

Mais je peux encore faire mieux. Je peux carrément rejeter son null quand il me le donne explicitement avec une NullPointerException :

public MonObjet(String monParametre){ 
    if(monParametre == null) 
        throw new NullPointerException("monParametre ne peut pas etre null");
    
    //Du code 
}

Alors oui, le code de mon collègue va générer une NullPointerExecption. Mais il va s’en rendre compte tout de suite à la première compilation. Et il saura qu’il ne doit pas utiliser null. Même s’il n’a pas lu la javadoc. Même s’il a ignoré les annotations.

De mon côté, en refusant le null, je suis certain que le paramètre est bon : plus besoin de tester sa nullité. Si c’est le seul endroit où il peut être mis à jour, je sais que, pendant toute la vie de mon objet, l’attribut qui stocke la valeur de ce paramètre ne sera jamais null. Plus besoin de rajouter des tests supplémentaires ailleurs, on est tranquille parce qu’on refuse ce qui ne va pas dès le départ : c’est ce que l’on appelle de la programmation défensive !

En pratique, c’est très utile quand on travaille à plusieurs ou sur des librairies qui seront diffusées largement. Le code est garanti de fonctionner convenablement. Si vous êtes seul maître à bord, on peut toutefois considérer que vous maîtrisez votre code et que vous ne passerez pas null à vos objets si vous savez que ça va tout casser. Tout dépend de votre niveau de confiance en vous-même.

Note : je n’utilise pas ici le mot clé assert pour tester mes paramètres. D’une part parce que ce n’est pas recommandé pour les API publics, d’autre part parce que c’est retiré à la compilation : vos versions release ne les contiendront plus donc vous n’êtes plus protégés. Il est néanmoins possible d’utiliser ce mot clé pour vos méthodes privées lors de vos développements, en gardant à l’esprit que ce ne sont que des contrôles de développement (comme les annotations).

Classes et librairies utiles

Objects

La classe Objects contient des méthodes pour tester la nullité des objets. Certaines sont accessibles depuis l’API 19, d’autres depuis l’API 24.

En pratique, ce sont surtout des raccourcis pour éviter plusieurs lignes de tests. Par exemple, la méthode requireNotNull remplace la génération de la NullPointerException :

public MonObjet(String monParametre){ 
    if(monParametre == null) 
        throw new NullPointerException("monParametre ne peut pas etre null"); 
    
    //Du code 
}

est identique à

public MonObjet(String monParametre){ 
    Objects.requireNonNull(monParametre, "monParametre ne peut pas etre null");

    //Du code 
}

N’hésitez pas à y jeter un oeil, c’est toujours pratique.

Librairie avec assertion

Il est possible d’utiliser des librairies qui proposent des méthodes de test et vérification des arguments et pré-conditions des algorithmes. C’est par exemple le cas de Guava.

Ces méthodes reprennent, grosso modo, ce que j’ai expliqué plus haut (notamment sur la programmation défensive) en proposant une alternative accessible à plus de version d’Android.

Néanmoins, gardez à l’esprit que l’utilisation de librairie externe à un coût et qu’il n’est pas forcément judicieux de surcharger son application avec trop d’éléments.

Et Kotlin ?

L’introduction de Kotlin comme langage pour le développement Android change la donne sur les problématiques de NullPointerException.

En effet, Kotlin a été pensé de façon à limiter ce genre d’erreur : il existe de nombreux mécanismes intrinsèque au langage qui empêche les NullPointerException ou limite leur propagation. En clair : si le compilateur a un doute, il génère une erreur de compilation et vous ne pourrez compiler que si le doute est levé.

Si l’on prend le code Kotlin suivant :

var a: String = "abc"
a = null 

Le compilateur va retourner une erreur parce que l’on affecte une valeur null à une variable. Vous ne pourrez compilez que si vous indiquez explicitement que la valeur peut être null en ajoutant un point d’interrogation :

var a: String ?= "abc"
a = null 

Bien entendu, tout appel non sûr à la variable a ensuite génèrera une erreur de compilation également.

var l = a.length // génère une erreur de compilation car a peut-être null

Pour pallier à ce problème, vous allez devoir utiliser l’opérateur précédent ainsi que le Elvis Operator (?:) pour lever les restrictions de compilation :

var l = a?.length ?: -1

Si a est null, alors l’appel à length n’a pas lieu et l prendra la valeur -1 à la place.

Il existe d’autres mécanismes en Kotlin pour garantir une meilleure sécurité du code, référencé sur le site du langage : https://kotlinlang.org/docs/reference/null-safety.html