Java – “Helpful NullPointerExceptions” da Java 14

Nel JDK 14 è stata aggiunta una nuova funzionalità della JVM chiamata “Helpful NullPointerExceptions” che è stata sviluppata e portata avanti tramite la JEP-358 (JEP=JDK Enhancement Proposals). Si tratta della possibilità di ottenere dalla JVM dei messaggi di eccezione molto più “parlanti” nel solo caso di NullPointerException lanciati direttamente dalla JVM (e attenzione, non “programmaticamente” dalla applicazione!).

Consideriamo il seguente metodo scritto appositamente come semplice esempio:

    public static String[] extractToUppercase(String[] strings, List<Integer> indexes) {
        String[] result = new String[indexes.size()];
        int i = 0;

        for (Integer index : indexes) {
            result[i++] = strings[index].toUpperCase();
        }

        return result;
    }

L’obiettivo del metodo dovrebbe essere abbastanza chiaro: dato un array di stringhe e data una lista di indici (come oggetti Integer), si vuole prendere solo le stringhe agli indici richiesti, portarle in upper case e inserirle in un nuovo array da restituire.

Ora, la questione che ci interessa principalmente è la seguente: che cosa potrebbe “andare storto” in questo metodo? Una cosa sicuramente evidente è la eventualità che un indice in indexes sia fuori dal range consentito nell’array strings, causando quindi una eccezione ArrayIndexOutOfBoundsException. Ai fini dell’articolo, questo caso specifico non ci interessa particolarmente, pertanto lo possiamo tranquillamente ignorare.

Esiste però la possibilità che venga lanciato quello che nel gergo di Java si dice un NPE, ovvero la ben nota (e infausta) eccezione java.lang.NullPointerException. Oltretutto, in quel metodo ci sono ben 4 scenari differenti che possono portare ad un NPE e sono esattamente i seguenti:

  1. il parametro indexes potrebbe essere null  (non si può fare la iterazione, né in generale invocare i metodi di List come il size())
  2. il parametro strings potrebbe essere null  (non si può accedere ad un elemento dell’array)
  3. un indice contenuto nella lista indexes, quindi il valore di index, potrebbe essere null  (non si può ottenere l’indice come valore primitivo int)
  4. una stringa contenuta nell’array strings potrebbe essere null  (non si può invocare toUpperCase())

Tutti questi casi potrebbero benissimo essere controllati e trattati all’interno del metodo, ad esempio per: a) ignorare i null oppure b) fare altro oppure ancora c) lanciare un’altra eccezione. Ma supponiamo che non si voglia farlo perché ci si aspetta che il chiamante faccia sempre (si spera...) la cosa giusta e non passi mai al metodo dei valori che possono causare un NPE.

Il chiamante però potrebbe non essere così attento, quindi proviamo con il seguente codice a causare esplicitamente un NPE passando dei valori non appropriati.

import java.util.Arrays;
import java.util.List;

public class ProvaNPE {
    public static void main(String[] args) {
        // Causa del NPE: il parametro indexes è null
        try {
            extractToUppercase(new String[] {"rosso", "giallo", "verde"}, null);
        } catch (Exception e) {
            e.printStackTrace();
        }

        System.out.println("-----------------");

        // Causa del NPE: il parametro strings è null
        try {
            extractToUppercase(null, Arrays.asList(2, 0));
        } catch (Exception e) {
            e.printStackTrace();
        }

        System.out.println("-----------------");

        // Causa del NPE: un indice è null
        try {
            extractToUppercase(new String[] {"rosso", "giallo", "verde"}, Arrays.asList(null, 0));
        } catch (Exception e) {
            e.printStackTrace();
        }

        System.out.println("-----------------");

        // Causa del NPE: una stringa è null
        try {
            extractToUppercase(new String[] {"rosso", "giallo", null}, Arrays.asList(2, 0));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static String[] extractToUppercase(String[] strings, List<Integer> indexes) {
        String[] result = new String[indexes.size()];
        int i = 0;

        for (Integer index : indexes) {
            result[i++] = strings[index].toUpperCase();
        }

        return result;
    }
}

Se ad esempio con un JDK precedente al 14 si prova a compilare e avviare il codice, si ottiene questo:

Esempio con JDK 11:

javac ProvaNPE.java
poi
java ProvaNPE

java.lang.NullPointerException
        at ProvaNPE.extractToUppercase(ProvaNPE.java:42)
        at ProvaNPE.main(ProvaNPE.java:8)
-----------------
java.lang.NullPointerException
        at ProvaNPE.extractToUppercase(ProvaNPE.java:46)
        at ProvaNPE.main(ProvaNPE.java:17)
-----------------
java.lang.NullPointerException
        at ProvaNPE.extractToUppercase(ProvaNPE.java:46)
        at ProvaNPE.main(ProvaNPE.java:26)
-----------------
java.lang.NullPointerException
        at ProvaNPE.extractToUppercase(ProvaNPE.java:46)
        at ProvaNPE.main(ProvaNPE.java:35)

Lo stacktrace fornisce purtroppo informazioni abbastanza basilari e limitate. Sappiamo solo che è avvenuto un NullPointerException e sappiamo il sorgente e il numero di riga in cui questo è avvenuto. Queste informazioni sono già utili in generale e molte volte sono più che sufficienti per trovare il problema. Nel caso del metodo extractToUppercase queste informazioni non sono proprio utilissime, perché alla riga 46 esistono varie possibilità di ottenere un NPE e solo dallo stacktrace non si riesce a capire esattamente la causa.

La nuova funzionalità dal JDK 14 in avanti

Nel JDK 14 è stata aggiunta questa nuova funzionalità dei “Helpful NullPointerExceptions”. La funzionalità è disponibile ma non è attiva per default e deve essere attivata esplicitamente tramite una apposita opzione.
Nel JDK 15 invece questa funzionalità è già attiva per default e la si può eventualmente disattivare.

Per provare questa nuova funzionalità con il JDK 14 bisogna ricompilare e riavviare:

Esempio con JDK 14:

javac -g ProvaNPE.java
poi
java -XX:+ShowCodeDetailsInExceptionMessages ProvaNPE

L’opzione -g in compilazione serve per attivare le informazioni di debugging che il compilatore inserisce nel file .class, invece l’opzione -XX:+ShowCodeDetailsInExceptionMessages all’avvio serve per attivare la nuova funzionalità.

In output si ottiene:

java.lang.NullPointerException: Cannot invoke "java.util.List.size()" because "indexes" is null
        at ProvaNPE.extractToUppercase(ProvaNPE.java:42)
        at ProvaNPE.main(ProvaNPE.java:8)
-----------------
java.lang.NullPointerException: Cannot load from object array because "strings" is null
        at ProvaNPE.extractToUppercase(ProvaNPE.java:46)
        at ProvaNPE.main(ProvaNPE.java:17)
-----------------
java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "index" is null
        at ProvaNPE.extractToUppercase(ProvaNPE.java:46)
        at ProvaNPE.main(ProvaNPE.java:26)
-----------------
java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "strings[java.lang.Integer.intValue()]" is null
        at ProvaNPE.extractToUppercase(ProvaNPE.java:46)
        at ProvaNPE.main(ProvaNPE.java:35)

Lo stacktrace ora fornisce delle informazioni decisamente più utili e soprattutto molto più “parlanti”.
In modo specifico:

  • Cannot invoke "java.util.List.size()" because "indexes" is null:
    descrive che la lista indexes è null, quindi non si può invocare il metodo size()

  • Cannot load from object array because "strings" is null:
    descrive che l’array strings è null, quindi non si può accedere agli elementi

  • Cannot invoke "java.lang.Integer.intValue()" because "index" is null:
    descrive che la variabile index è null, quindi non si può ottenere l’indice come valore primitivo int

  • Cannot invoke "String.toUpperCase()" because "strings[java.lang.Integer.intValue()]" is null:
    descrive che una stringa contenuta nell’array strings è null, quindi non si può invocare il metodo toUpperCase()

Utilizzo della opzione -g

Qualcuno potrebbe domandarsi se l’opzione -g che si passa al compilatore javac (per un IDE c’è sicuramente un settaggio corrispondente) serve veramente e per che cosa. Questa opzione serve se si vuole avere il dettaglio esatto sui nomi di variabili/parametri.

Se si compila senza l’opzione -g, poi a runtime l’output che si ottiene è come il seguente:

java.lang.NullPointerException: Cannot invoke "java.util.List.size()" because "<parameter2>" is null
        at ProvaNPE.extractToUppercase(ProvaNPE.java:42)
        at ProvaNPE.main(ProvaNPE.java:8)
-----------------
java.lang.NullPointerException: Cannot load from object array because "<parameter1>" is null
        at ProvaNPE.extractToUppercase(ProvaNPE.java:46)
        at ProvaNPE.main(ProvaNPE.java:17)
-----------------
java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local5>" is null
        at ProvaNPE.extractToUppercase(ProvaNPE.java:46)
        at ProvaNPE.main(ProvaNPE.java:26)
-----------------
java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "<parameter1>[java.lang.Integer.intValue()]" is null
        at ProvaNPE.extractToUppercase(ProvaNPE.java:46)
        at ProvaNPE.main(ProvaNPE.java:35)

Al posto dei nomi reali di variabili/parametri ci sono solo dei generici “segnaposto”, che ovviamente rendono meno facile la comprensione del problema.

Limiti della nuova funzionalità

La JEP-358 descrive bene se/come i messaggi dettagliati sul NullPointerException sono disponibili. Per fare un breve riassunto:

  • Sono disponibili solo per gli NPE che vengono creati e lanciati direttamente dalla JVM

  • Non sono disponibili se si crea/lancia “programmaticamente” un NPE, es. if (cond) { throw new NullPointerException(); } perché la JVM non è in grado di analizzare il bytecode in questi punti

  • Non sono disponibili se il NPE è causato da parti di codice generate dalla JVM