Java : la NullPointerException sortie de nulle part ?

Les ternaires et l’auto(un)boxing sont dans un bateau…

Une exception sauvage apparaît !

Ce code, si on l’appelle de la façon suivante : parseInteger(null, null) plante avec une NullPointerException à la ligne 4.

    public static Integer parseInteger(final String s, final Integer defaultValue) {
        try {
            return s == null
                    ? defaultValue
                    : Integer.parseInt(s);
        } catch (NumberFormatException e) {
            Log.trace("Can’t convert " + s + " to Integer, use default value " + defaultValue);
            return defaultValue;
        }
    }

Étrange, non ?

Pourtant en première approche, on pourrait croire que si le premier paramètre s est null, on se contente de renvoyer le second paramètre defaultValue qui peut aussi être null.

Alors pourquoi ça plante ? D’où sort cette NullPointerException ?

Pour le comprendre, il va falloir creuser dans les notions de « Boxing », d’« Unboxing » et de la priorité dans les opérateurs ternaires.

Boxing, unboxing

Ces deux notions sont normalement connues des développeurs expérimentés en Java, mais pour les débutants et les curieux, je la réexplique.

La JVM (qui fait tourner les programmes Java), ne connait que 9 types pour manipuler les données :

  • Les huit types « natifs », « primitifs » ou « de base » : boolean, byte, short (presque jamais utilisé), int, long, float, double et char ;
  • Et Les objets, indépendamment des données qu’ils contiennent.

String a droit à un traitement un peu particulier, parce que techniquement c’est un objet, mais il a droit à des spécificités intégrées directement dans la JVM. De même avec les exceptions (plus exactement, tout ce qui implémente Throwable à un degré ou un autre).

Et… c’est tout. Absolument tout ce qu’on peut faire en Java se repose sur ces concepts.

Le problème là-dedans, c’est que les types natifs ne sont pas des objets. Ça veut dire que :

  1. Ils ont des comportements différents (par exemple, ils sont passés par copie et pas par référence dans les méthodes), et surtout
  2. On ne peut pas les utiliser là où on a besoin d’un objet.

En particulier, ça veut dire qu’ils ne peuvent pas être null, la notation désignant une étiquette (variable ou autre) qui n’est pas rattachée à un objet en mémoire. Ils ne peuvent pas non plus être utilisés dans les mécanismes tels que la généricité, qui ne fonctionne qu’avec des objets.

Pour pouvoir utiliser ces types en tant qu’objets, pour chaque type natif, on a créé un objet correspondant (Boolean, Byte, Short, Integer, Long, Float, Double et Character – ça n’est pas toujours « juste le même nom avec une majuscule », c’eut été trop simple). Mais si on gagne la possibilité d’utiliser nos types comme des objets, on perd plein de choses dans l’opération. Par exemple, aucune arithmétique n’est possible sur les objets. Pas très pratique.

Jusqu’en Java 1.4 inclus, il fallait faire les conversion entre les types natifs et leurs objets à la main. Ça impliquait d’écrire beaucoup de code trivial – parce que c’est une opération dont on a souvent besoin –, ce qui a aidé à cette réputation de verbosité de Java.

Et puis on s’est dit que c’était quand même con de passer du temps à écrire du code trivial sans aucune valeur ajoutée, et qu’on pouvait déléguer le boulot en sous-marin au compilateur, qui va ajouter tout seul les conversions en type natif ou en objet au besoin.

C’est ce qu’on appelle l’auto-boxing (on range automatiquement notre type natif dans une boite : son objet) et l’auto-unboxing (l’opération inverse).

Et ça marche tellement bien qu’en général on oublie complètement que ça existe.

Mais alors, pourquoi le code plante ?

Reprenons le code (je supprime la gestion d’exception pour la lisibilité, ce code ne compile pas en l’état) :

    public static Integer parseInteger(final String s, final Integer defaultValue) {
        return s == null
                ? defaultValue
                : Integer.parseInt(s);
    }

Les types de sortie de la méthode comme de la valeur par défaut sont Integer, donc l’objet « entier de 32 bits signé ». Comme tout objet en Java (hélas), ils peuvent être null.

Par contre, Integer.parseInt(String) renvoie un int, le type natif. Donc il y a un boxing ou un unboxing caché dans cette histoire pour que ça puisse fonctionner.

Puisque le type du retour et le premier paramètre de la condition ternaire utilisent chacun objet Integer, on pourrait supposer que le compilateur va être intelligent et nous faire un beau boxing pour gérer tout ça à moindre frais :

    public static Integer parseInteger(final String s, final Integer defaultValue) {
        return s == null
                ? defaultValue
                : Integer.valueOf(Integer.parseInt(s));
    }

Mais non ! Les règles de gestion de l’auto(un)boxing couplées à celles des ternaires font que le compilateur va plutôt choisir de convertir les deux branches de la condition en type natif (en suivant la directive du « else », puis de re-convertir le tout en objet avant de le renvoyer :

    public static Integer parseInteger(final String s, final Integer defaultValue) {
        return Integer.valueOf(s == null
                ? defaultValue.intValue()
                : Integer.parseInt(s));
    }

Et là, c’est le drame : à la ligne 3, on essaie d’appeler une méthode sur un objet null… et ça renvoie la NullPointerException observée. Et comme NullPointerException n’est pas une fille de la NumberFormatException que l’on attrape, la méthode plante.

La combinaison de ternaires et de l’autoboxing peut donc provoquer des erreurs là où le code a l’air tout à fait inoffensif.

Parce que oui, la version avec un if normal se comporte bien comme prévu et ne plante pas quand on l’appelle ainsi : parseInteger(null, null).

    public static Integer parseInteger(final String s, final Integer defaultValue) {
        try {
            if (s == null) {
                return defaultValue;
            }
            return Integer.parseInt(s);
        } catch (NumberFormatException e) {
            Log.trace("Can’t convert " + s + " to Integer, use default value " + defaultValue);
            return defaultValue;
        }
    }

Ou même sa version avec une seule instruction return :

    public static Integer parseInteger2(final String s, final Integer defaultValue) {
        Integer result = defaultValue;
        try {
            if (s != null) {
                result = Integer.parseInt(s);
            }
        } catch (NumberFormatException e) {
            Log.trace("Can’t convert " + s + " to Integer, use default value " + defaultValue);
        }
        return result;
    }

Attention aux effets de bord imprévus si vous aimez jouer avec la fonction « Remplacer ce if par un ternaire » de votre IDE favori !



6 commentaires

Ça mériterait pas un rapport de bug ? Le compilateur fait un choix de caster implicitement en un type incapable de représenter certaines valeurs de départ. Au-delà du mauvais design du système de type qui est probablement trop fondamental pour être corrigé, on peut déjà faire le choix d’éviter de caster implicitement un Integer en int à tout prix tant que ce n’est pas nécessaire (ou que la conversion peut être prouvée correcte, mais c’est vite complexe et assez limité, surtout sans système de type sur lequel s’appuyer)…

Je ne pense pas : la notion même d’unboxing implique de transformer (ça n’est pas du cast à strictement parler) un objet qui peut être null en un type natif qui ne peut pas l’être, en partant du principe que le développeur sait à peu près ce qu’il fait.

Par exemple ceci compile et ne peut pas planter (on est d’accord que c’est une fonction qui n’a pas de sens, c’est juste pour l’exemple) :

public Integer convert(final int i) {
    return i;
}

Mais ceci compile aussi et plantera si on lui passe null :

public int convert(final Integer i) {
    return i;
}

Parce qu’en fait c’est un simple raccourci d’écriture pour :

public int convert(final Integer i) {
    return i.intValue();
}

Et donc la signature de la méthode est respectée, et il n’y a pas de vérification de null à la compilation en Java.

Cela dit, on est d’accord pour dire que le système de typage de Java est fondamentalement daubé, la faute à :

  1. Un historique lourd où les premières versions étaient très (trop ?) proches de la JVM et exposaient ses mécanismes internes dans le langage (les types natifs), et
  2. Un refus farouche de la rupture de compatibilité qui pourrait corriger ça, et surtout
  3. Aucune gestion native des null à la compilation (un déréférencement c’est une exception non-ckeckée).

Si on prends Kotlin, qui peut aussi tourner sur la JVM, sa gestion des types est bien plus propre. Notamment, un type ne peut jamais être null à moins de le définir explicitement avec un ? à la fin du nom. Par exemple, Int est un entier toujours défini, Int? est un entier ou null. Et il y des opérateurs pour gérer ça.

La fonction de conversion deviendrait :

fun parseInteger(s: String?, defaultValue: Int?): Int? {
    return try {
        if (s == null) defaultValue else s.toInt()
    } catch (e: NumberFormatException) {
        Log.trace("Can’t convert " + s + " to Integer, use default value " + defaultValue);
        defaultValue
    }
}

Ou, de façon plus canonique mais moins lisible quand on a pas l’habitude des opérateurs de gestion des null :

fun parseInteger(s: String?, defaultValue: Int?): Int? {
    return try {
        s?.toInt() ?: defaultValue
    } catch (e: NumberFormatException) {
        Log.trace("Can’t convert " + s + " to Integer, use default value " + defaultValue);
        defaultValue
    }
}

(Avec .? le safe call et ?: le Elvis operator, cf ici pour une explication).

Une explication sur le pourquoi ce choix (ou peut-être ce non-choix) à propos de l’opérateur ternaire ?

Visiblement ce n’est qu’une question d’autoboxing et non pas de priorité, car le comportement est le même dans les deux sens:

return s==null? defaultValue: Integer.parseInt(s);
vs return s!=null? Integer.parseInt(s) : defaultValue;

Dans la pratique, le type Boolean avec un B majuscule est bien plus puissant et plus vicieux pour sortir des NPE de nulle part. Ne jamais l’utiliser pour implémenter une logique à trois états (true, false, indéterminé=null) !

En tout cas merci pour ce petit partage.

+0 -0

J’ai pas l’explication du pourquoi, mais la spécification est disponible ici. Visiblement on tombe dans une combinaison de règles assez sale.

Dans la pratique, le type Boolean avec un B majuscule est bien plus puissant et plus vicieux pour sortir des NPE de nulle part. Ne jamais l’utiliser pour implémenter une logique à trois états (true, false, indéterminé=null) !

Complètement d’accord, d’ailleurs les outils d’analyse de code crachent des beaux warnings sur les Boolean. Il peut servir en ternaire si tu sais exactement ce que tu fais, et notamment que tu évites les blagues du style :

private void test(Boolean b) {
    if (b) {
        // do something
    }
}

Visiblement on tombe dans une combinaison de règles assez sale.

En fait pas tant que ça. Ca parait assez logique quand on y réfléchit bien. ON a plus souvent besoin de la conversion Integer->int que l’inverse, et aussi dans un souci d’optimisation.

If one of the second and third operands is of primitive type T, and the type of the other is the result of applying boxing conversion ( §5.1.7) to T, then the type of the conditional expression is T.

En réalité pour éviter le problème, il faut utiliser Integer.valueOf(String) ici.

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte