Evitare il controllo dell’istruzione Null in Java
Panoramica
Generalmente, le variabili null, i riferimenti e le raccolte sono difficili da gestire nel codice Java. Non solo sono difficili da identificare, ma sono anche complessi da affrontare.
In effetti, qualsiasi mancanza nel trattare con null non può essere identificata in fase di compilazione e si traduce in una NullPointerException in fase di runtime.
In questo tutorial, daremo un’occhiata alla necessità di controllare null in Java e varie alternative che ci aiutano ad evitare controlli null nel nostro codice.
Further reading:
Using NullAway to Avoid NullPointerExceptions
Spring Null-Safety Annotations
Introduction to the Null Object Pattern
What Is NullPointerException?
Secondo il Javadoc per NullPointerException, è generata quando un’applicazione tenta di utilizzare null nel caso in cui un oggetto è necessario, come:
- la Chiamata di un metodo di istanza di un oggetto null
- Accesso o la modifica di un campo di un oggetto null
- Prendere la lunghezza di null come se fosse un array
- Accedere o modificare le fessure di null come se fosse un array
- restituisce come se si trattasse di un Throwable valore
Vediamo rapidamente di vedere un paio di esempi di codice Java che causa questa eccezione:
public void doSomething() { String result = doSomethingElse(); if (result.equalsIgnoreCase("Success")) // success }}private String doSomethingElse() { return null;}
Qui, abbiamo provato a richiamare una chiamata al metodo per un riferimento null. Ciò comporterebbe una NullPointerException.
Un altro esempio comune è se proviamo ad accedere a un array null:
public static void main(String args) { findMax(null);}private static void findMax(int arr) { int max = arr; //check other elements in loop}
Questo causa una NullPointerException alla riga 6.
Pertanto, l’accesso a qualsiasi campo, metodo o indice di un oggetto null causa una NullPointerException, come si può vedere dagli esempi sopra.
Un modo comune per evitare NullPointerException è controllare null:
public void doSomething() { String result = doSomethingElse(); if (result != null && result.equalsIgnoreCase("Success")) { // success } else // failure}private String doSomethingElse() { return null;}
Nel mondo reale, i programmatori trovano difficile identificare quali oggetti possono essere nulli. Una strategia aggressivamente sicura potrebbe essere quella di controllare null per ogni oggetto. Questo, tuttavia, causa molti controlli nulli ridondanti e rende il nostro codice meno leggibile.
Nelle prossime sezioni, esamineremo alcune delle alternative in Java che evitano tale ridondanza.
Gestione di null Tramite il Contratto API
Come discusso nell’ultima sezione, l’accesso a metodi o variabili di oggetti null causa una NullPointerException. Abbiamo anche discusso che mettere un controllo null su un oggetto prima di accedervi elimina la possibilità di NullPointerException.
Tuttavia, spesso ci sono API in grado di gestire valori null. Ad esempio:
public void print(Object param) { System.out.println("Printing " + param);}public Object process() throws Exception { Object result = doSomething(); if (result == null) { throw new Exception("Processing fail. Got a null response"); } else { return result; }}
La chiamata al metodo print() stamperebbe semplicemente “null” ma non genererà un’eccezione. Allo stesso modo, process() non restituirebbe mai null nella sua risposta. Piuttosto getta un’eccezione.
Quindi per un codice client che accede alle API di cui sopra, non è necessario un controllo null.
Tuttavia, tali API devono renderlo esplicito nel loro contratto. Un luogo comune per le API per pubblicare tale contratto è JavaDoc.
Questo, tuttavia, non fornisce alcuna chiara indicazione del contratto API e quindi si basa sugli sviluppatori di codice client per garantire la sua conformità.
Nella prossima sezione, vedremo come alcuni IDE e altri strumenti di sviluppo aiutano gli sviluppatori con questo.
Automazione dei contratti API
4.1. Utilizzando l’analisi del codice statico
Strumenti di analisi del codice statico contribuire a migliorare la qualità del codice per un grande affare. E alcuni di questi strumenti consentono anche agli sviluppatori di mantenere il contratto nullo. Un esempio è FindBugs.
FindBugs aiuta a gestire il contratto null tramite le annotazioni @Nullable e @NonNull. Possiamo usare queste annotazioni su qualsiasi metodo, campo, variabile locale o parametro. Ciò rende esplicito al codice client se il tipo annotato può essere nullo o meno. Vediamo un esempio:
public void accept(@Nonnull Object param) { System.out.println(param.toString());}
Qui, @NonNull chiarisce che l’argomento non può essere nullo. Se il codice client chiama questo metodo senza controllare l’argomento per null, FindBugs genererebbe un avviso in fase di compilazione.
4.2. Utilizzando il supporto IDE
Gli sviluppatori generalmente si affidano agli IDE per scrivere codice Java. E caratteristiche come il completamento del codice intelligente e gli avvisi utili, come quando una variabile potrebbe non essere stata assegnata, certamente aiutano in larga misura.
Alcuni IDE consentono inoltre agli sviluppatori di gestire i contratti API e quindi di eliminare la necessità di uno strumento di analisi del codice statico. IntelliJ IDEA fornisce le annotazioni @NonNull e @Nullable. Per aggiungere il supporto per queste annotazioni in IntelliJ, dobbiamo aggiungere la seguente dipendenza Maven:
<dependency> <groupId>org.jetbrains</groupId> <artifactId>annotations</artifactId> <version>16.0.2</version></dependency>
Ora, IntelliJ genererà un avviso se manca il controllo null, come nel nostro ultimo esempio.
IntelliJ fornisce anche un’annotazione del contratto per la gestione di contratti API complessi.
5. Asserzioni
Fino ad ora, abbiamo solo parlato di rimuovere la necessità di controlli nulli dal codice client. Ma, che è raramente applicabile nelle applicazioni del mondo reale.
Ora, supponiamo che stiamo lavorando con un’API che non può accettare parametri null o può restituire una risposta null che deve essere gestita dal client. Questo presenta la necessità per noi di controllare i parametri o la risposta per un valore null.
Qui, possiamo usare Asserzioni Java invece della tradizionale istruzione condizionale null check:
public void accept(Object param){ assert param != null; doSomething(param);}
Nella riga 2, controlliamo un parametro null. Se le asserzioni sono abilitate, ciò comporterebbe un AssertionError.
Sebbene sia un buon modo per affermare pre-condizioni come parametri non nulli, questo approccio ha due problemi principali:
- Le asserzioni sono di solito disabilitate in una JVM
- Una falsa asserzione produce un errore non controllato che è irrecuperabile
Quindi, non è consigliabile per i programmatori usare Asserzioni per controllare le condizioni. Nelle sezioni seguenti, discuteremo altri modi di gestire le convalide null.
Evitare controlli null attraverso pratiche di codifica
6.1. Precondizioni
Di solito è una buona pratica scrivere codice che fallisce presto. Pertanto, se un’API accetta più parametri che non possono essere null, è preferibile verificare ogni parametro non null come precondizione dell’API.
Ad esempio, diamo un’occhiata a due metodi – uno che fallisce presto e uno che non lo fa:
public void goodAccept(String one, String two, String three) { if (one == null || two == null || three == null) { throw new IllegalArgumentException(); } process(one); process(two); process(three);}public void badAccept(String one, String two, String three) { if (one == null) { throw new IllegalArgumentException(); } else { process(one); } if (two == null) { throw new IllegalArgumentException(); } else { process(two); } if (three == null) { throw new IllegalArgumentException(); } else { process(three); }}
Chiaramente, dovremmo preferire goodAccept() su badAccept().
In alternativa, possiamo anche utilizzare le precondizioni di Guava per la convalida dei parametri API.
6.2. Usando le primitive invece delle classi Wrapper
Poiché null non è un valore accettabile per le primitive come int, dovremmo preferirle rispetto alle loro controparti wrapper come Integer laddove possibile.
Considera due implementazioni di un metodo che somma due numeri interi:
public static int primitiveSum(int a, int b) { return a + b;}public static Integer wrapperSum(Integer a, Integer b) { return a + b;}
Ora, chiamiamo queste API nel nostro codice client:
int sum = primitiveSum(null, 2);
Ciò comporterebbe un errore in fase di compilazione poiché null non è un valore valido per un int.
E quando si utilizza l’API con classi wrapper, otteniamo una NullPointerException:
assertThrows(NullPointerException.class, () -> wrapperSum(null, 2));
Ci sono anche altri fattori per l’utilizzo di primitive su wrapper, come abbiamo trattato in un altro tutorial, Java Primitive versus Objects.
6.3. Collezioni vuote
Occasionalmente, abbiamo bisogno di restituire una raccolta come risposta da un metodo. Per tali metodi, dovremmo sempre provare a restituire una raccolta vuota invece di null:
public List<String> names() { if (userExists()) { return Stream.of(readName()).collect(Collectors.toList()); } else { return Collections.emptyList(); }}
Quindi, abbiamo evitato la necessità per il nostro client di eseguire un controllo null quando si chiama questo metodo.
Utilizzando gli oggetti
Java 7 ha introdotto la nuova API Objects. Questa API ha diversi metodi di utilità statici che portano via un sacco di codice ridondante. Diamo un’occhiata a uno di questi metodi, requireNonNull():
public void accept(Object param) { Objects.requireNonNull(param); // doSomething()}
Ora, testiamo il metodo accept ():
assertThrows(NullPointerException.class, () -> accept(null));
Quindi, se null viene passato come argomento, accept() genera una NullPointerException.
Questa classe ha anche Isnull() e Nonnull() metodi che possono essere utilizzati come predicati per controllare un oggetto per null.
Utilizzando opzionale
8.1. Utilizzando orElseThrow
Java 8 ha introdotto una nuova API opzionale nella lingua. Ciò offre un contratto migliore per la gestione dei valori opzionali rispetto a null. Vediamo come Optional toglie la necessità di controlli null:
public Optional<Object> process(boolean processed) { String response = doSomething(processed); if (response == null) { return Optional.empty(); } return Optional.of(response);}private String doSomething(boolean processed) { if (processed) { return "passed"; } else { return null; }}
Restituendo un Optional, come mostrato sopra, il metodo process chiarisce al chiamante che la risposta può essere vuota e deve essere gestita in fase di compilazione.
Ciò elimina in particolare la necessità di eventuali controlli nulli nel codice client. Una risposta vuota può essere gestita in modo diverso utilizzando lo stile dichiarativo dell’API opzionale:
assertThrows(Exception.class, () -> process(false).orElseThrow(() -> new Exception()));
Inoltre, fornisce anche un contratto migliore agli sviluppatori API per indicare ai client che un’API può restituire una risposta vuota.
Sebbene abbiamo eliminato la necessità di un controllo null sul chiamante di questa API, l’abbiamo usato per restituire una risposta vuota. Per evitare ciò, Optional fornisce un metodo ofNullable che restituisce un Optional con il valore specificato, o vuoto, se il valore è null:
public Optional<Object> process(boolean processed) { String response = doSomething(processed); return Optional.ofNullable(response);}
8.2. Utilizzando Opzionale con collezioni
Mentre si tratta di collezioni vuote, Opzionale è utile:
public String findFirst() { return getList().stream() .findFirst() .orElse(DEFAULT_VALUE);}
Questa funzione dovrebbe restituire il primo elemento di un elenco. La funzione findFirst dell’API Stream restituirà un facoltativo vuoto quando non ci sono dati. Qui, abbiamo usato orElse per fornire invece un valore predefinito.
Questo ci consente di gestire elenchi vuoti o elenchi, che dopo aver utilizzato il metodo di filtro della libreria Stream, non hanno elementi da fornire.
In alternativa, possiamo anche consentire al client di decidere come gestire empty restituendo Optional da questo metodo:
public Optional<String> findOptionalFirst() { return getList().stream() .findFirst();}
Pertanto, se il risultato di getList è vuoto, questo metodo restituirà un opzionale vuoto al client.
L’utilizzo di Optional with collections ci consente di progettare API che sono sicure di restituire valori non null, evitando così controlli null espliciti sul client.
È importante notare qui che questa implementazione si basa su getList che non restituisce null. Tuttavia, come abbiamo discusso nell’ultima sezione, è spesso meglio restituire una lista vuota piuttosto che un null.
8.3. Combinando gli Optionals
Quando iniziamo a rendere le nostre funzioni opzionali, abbiamo bisogno di un modo per combinare i loro risultati in un unico valore. Prendiamo il nostro esempio getList da prima. Cosa succede se dovesse restituire un elenco facoltativo, o dovesse essere avvolto con un metodo che ha avvolto un null con Opzionale usando ofNullable?
Il nostro metodo findFirst vuole restituire un primo elemento opzionale di una lista opzionale:
public Optional<String> optionalListFirst() { return getOptionalList() .flatMap(list -> list.stream().findFirst());}
Utilizzando la funzione flatMap sull’opzionale restituito da getOptional possiamo decomprimere il risultato di un’espressione interna che restituisce opzionale. Senza flatMap, il risultato sarebbe Opzionale<Opzionale<String>>. L’operazione flatMap viene eseguita solo quando l’opzione non è vuota.
Librerie
9.1. Utilizzando Lombok
Lombok è una grande libreria che riduce la quantità di codice boilerplate nei nostri progetti. Viene fornito con una serie di annotazioni che prendono il posto di parti comuni di codice che spesso scriviamo noi stessi in applicazioni Java, come getter, setter e toString (), per citarne alcuni.
Un’altra delle sue annotazioni è @NonNull. Quindi, se un progetto utilizza già Lombok per eliminare il codice boilerplate, @NonNull può sostituire la necessità di controlli null.
Prima di passare a vedere alcuni esempi, aggiungiamo un Esperto di dipendenza per Lombok:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.6</version></dependency>
Ora, siamo in grado di utilizzare @ovunque un valore diverso da null null è necessario un controllo:
public void accept(@NonNull Object param){ System.out.println(param);}
Così, abbiamo semplicemente annotato l’oggetto per il quale il controllo di null sarebbe stato richiesto, e Lombok genera la classe compilata:
public void accept(@NonNull Object param) { if (param == null) { throw new NullPointerException("param"); } else { System.out.println(param); }}
Se il parametro è null, questo metodo genera una NullPointerException. Il metodo deve renderlo esplicito nel suo contratto e il codice client deve gestire l’eccezione.
9.2. Usando StringUtils
Generalmente, la convalida delle stringhe include un controllo per un valore vuoto oltre al valore null. Pertanto, una dichiarazione di convalida comune sarebbe:
public void accept(String param){ if (null != param && !param.isEmpty()) System.out.println(param);}
Questo diventa rapidamente ridondante se abbiamo a che fare con un sacco di tipi di stringa. Questo è dove StringUtils viene utile. Prima di vederlo in azione, aggiungiamo una dipendenza Maven per commons-lang3:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.11</version></dependency>
Rifattorizziamo ora il codice sopra con StringUtils:
public void accept(String param) { if (StringUtils.isNotEmpty(param)) System.out.println(param);}
Quindi, abbiamo sostituito il nostro controllo nullo o vuoto con un metodo di utilità statico isNotEmpty(). Questa API offre altri potenti metodi di utilità per la gestione di funzioni di stringa comuni.
Conclusione
In questo articolo, abbiamo esaminato i vari motivi per NullPointerException e perché è difficile da identificare. Quindi, abbiamo visto vari modi per evitare la ridondanza nel codice attorno al controllo di null con parametri, tipi di ritorno e altre variabili.
Tutti gli esempi sono disponibili su GitHub.
Inizia con Spring 5 e Spring Boot 2, attraverso il corso Learn Spring:
>>SCOPRI IL CORSO