evitați verificarea Declarației nule în Java
Prezentare generală
În general, variabilele, referințele și colecțiile nule sunt dificil de gestionat în codul Java. Nu numai că sunt greu de identificat, dar sunt și complexe.
de fapt, orice lipsă în tratarea null nu poate fi identificată la momentul compilării și are ca rezultat o NullPointerException în timpul rulării.
în acest tutorial, vom arunca o privire asupra necesității de a verifica null în Java și diverse alternative care ne ajută să evităm verificările null în codul nostru.
Further reading:
Using NullAway to Avoid NullPointerExceptions
Spring Null-Safety Annotations
Introduction to the Null Object Pattern
What Is NullPointerException?
conform Javadoc pentru NullPointerException, este aruncat atunci când o aplicație încearcă să utilizeze null într-un caz în care este necesar un obiect, cum ar fi:
- apelarea unei metode instanță a unui obiect null
- accesarea sau modificarea unui câmp al unui obiect null
- luând lungimea null ca și cum ar fi o matrice
- accesarea sau modificarea sloturile de null ca a fost o valoare aruncabilă
să vedem rapid câteva exemple de cod Java care provoacă această excepție:
public void doSomething() { String result = doSomethingElse(); if (result.equalsIgnoreCase("Success")) // success }}private String doSomethingElse() { return null;}
aici, am încercat să invocăm un apel de metodă pentru o referință nulă. Acest lucru ar duce la o NullPointerException.
un alt exemplu comun este dacă încercăm să accesăm o matrice nulă:
public static void main(String args) { findMax(null);}private static void findMax(int arr) { int max = arr; //check other elements in loop}
aceasta provoacă o NullPointerException la linia 6.
astfel, accesarea oricărui câmp, metodă sau index al unui obiect nul provoacă o NullPointerException, așa cum se poate vedea din exemplele de mai sus.
un mod comun de a evita NullPointerException este de a verifica pentru null:
public void doSomething() { String result = doSomethingElse(); if (result != null && result.equalsIgnoreCase("Success")) { // success } else // failure}private String doSomethingElse() { return null;}
în lumea reală, programatorilor le este greu să identifice ce obiecte pot fi nule. O strategie agresivă sigură ar putea fi verificarea null pentru fiecare obiect. Cu toate acestea, acest lucru provoacă o mulțime de verificări nule redundante și face codul nostru mai puțin lizibil.
în următoarele secțiuni, vom trece prin unele dintre alternativele din Java care evită o astfel de redundanță.
manipularea null prin contractul API
așa cum sa discutat în ultima secțiune, accesarea metode sau variabile de obiecte null provoacă o NullPointerException. De asemenea, am discutat că punerea unei verificări nule pe un obiect înainte de a-l accesa elimină posibilitatea NullPointerException.
cu toate acestea, de multe ori există API-uri care pot gestiona valori nule. De exemplu:
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; }}
apelul metodei print() ar imprima doar „null”, dar nu va arunca o excepție. În mod similar, proces() nu s-ar întoarce null în răspunsul său. Mai degrabă aruncă o excepție.
deci, pentru un cod client care accesează API-urile de mai sus, nu este nevoie de o verificare nulă.
cu toate acestea, astfel de API-uri trebuie să o facă explicită în contractul lor. Un loc comun pentru API-uri de a publica un astfel de contract este JavaDoc.
Acest lucru, cu toate acestea, nu oferă nici o indicație clară a contractului API și, prin urmare, se bazează pe dezvoltatorii de cod client pentru a asigura conformitatea acestuia.
în secțiunea următoare, vom vedea cum câteva IDE-uri și alte instrumente de dezvoltare ajută dezvoltatorii în acest sens.
automatizarea contractelor API
4.1. Folosind analiza codului Static
instrumente de analiză a Codului Static ajuta la îmbunătățirea calității codului la o mare. Și câteva astfel de instrumente permit, de asemenea, dezvoltatorilor să mențină contractul nul. Un exemplu este FindBugs.
FindBugs ajută la gestionarea contractului nul prin adnotările @nullable și @NonNull. Putem folosi aceste adnotări peste orice metodă, câmp, variabilă locală sau parametru. Acest lucru face explicit pentru codul clientului dacă tipul adnotat poate fi nul sau nu. Să vedem un exemplu:
public void accept(@Nonnull Object param) { System.out.println(param.toString());}
aici, @NonNull arată clar că argumentul nu poate fi nul. Dacă codul client apelează această metodă fără a verifica argumentul pentru null, FindBugs ar genera un avertisment la momentul compilării.
4.2. Folosind suportul IDE
dezvoltatorii se bazează în general pe IDE-uri pentru scrierea codului Java. Și funcții precum completarea Codului inteligent și avertismente utile, cum ar fi atunci când este posibil ca o variabilă să nu fi fost atribuită, ajută cu siguranță într-o mare măsură.
unele IDE permit, de asemenea, dezvoltatorilor să gestioneze contractele API și, prin urmare, să elimine necesitatea unui instrument de analiză a codului static. IntelliJ IDEA oferă adnotările @ NonNull și @Nullable. Pentru a adăuga suportul pentru aceste adnotări în IntelliJ, trebuie să adăugăm următoarea dependență Maven:
<dependency> <groupId>org.jetbrains</groupId> <artifactId>annotations</artifactId> <version>16.0.2</version></dependency>
acum, IntelliJ va genera un avertisment dacă verificarea nulă lipsește, ca în ultimul nostru exemplu.
IntelliJ oferă, de asemenea, o adnotare a contractului pentru gestionarea contractelor API complexe.
5. Afirmații
până acum, am vorbit doar despre eliminarea necesității verificărilor nule din Codul clientului. Dar, acest lucru este rar aplicabil în aplicațiile din lumea reală.
acum, să presupunem că lucrăm cu un API care nu poate accepta parametrii null sau poate returna un răspuns null care trebuie gestionat de client. Aceasta prezintă necesitatea de a verifica parametrii sau răspunsul pentru o valoare nulă.
aici, putem folosi afirmații Java în loc de declarația condițională tradițională de verificare nulă:
public void accept(Object param){ assert param != null; doSomething(param);}
în linia 2, verificăm un parametru nul. Dacă afirmațiile sunt activate, acest lucru ar duce la o AssertionError.
deși este o modalitate bună de a afirma pre-condiții, cum ar fi parametrii non-nul, această abordare are două probleme majore:
- afirmațiile sunt de obicei dezactivate într-un JVM
- o afirmație falsă are ca rezultat o eroare necontrolată care este irecuperabilă
prin urmare, nu este recomandat programatorilor să utilizeze afirmații pentru verificarea condițiilor. În secțiunile următoare, vom discuta alte modalități de manipulare a validărilor nule.
evitarea verificărilor nule prin practici de codificare
6.1. Condiții prealabile
de obicei, este o practică bună să scrieți cod care eșuează devreme. Prin urmare, dacă un API acceptă mai mulți parametri care nu sunt permise să fie null, este mai bine să verificați pentru fiecare parametru non-null ca o condiție prealabilă a API.
de exemplu, să ne uităm la două metode – una care eșuează devreme și una care nu:
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); }}
în mod clar, ar trebui să preferăm goodAccept() în locul badAccept().
ca alternativă, putem folosi și condițiile prealabile ale Guava pentru validarea parametrilor API.
6.2. Folosind primitive în loc de clase de înveliș
deoarece null nu este o valoare acceptabilă pentru primitive precum int, ar trebui să le preferăm în locul omologilor lor de înveliș precum Integer ori de câte ori este posibil.
luați în considerare două implementări ale unei metode care însumează două numere întregi:
public static int primitiveSum(int a, int b) { return a + b;}public static Integer wrapperSum(Integer a, Integer b) { return a + b;}
acum, să numim aceste API-uri în codul nostru client:
int sum = primitiveSum(null, 2);
aceasta ar duce la o eroare de compilare, deoarece null nu este o valoare validă pentru un int.
și atunci când se utilizează API cu clase de înveliș, vom obține un NullPointerException:
assertThrows(NullPointerException.class, () -> wrapperSum(null, 2));
există și alți factori pentru utilizarea primitivelor peste ambalaje, așa cum am acoperit într-un alt tutorial, primitive Java versus obiecte.
6.3. Colecții goale
ocazional, trebuie să returnăm o colecție ca răspuns dintr-o metodă. Pentru astfel de metode, ar trebui să încercăm întotdeauna să returnăm o colecție goală în loc de null:
public List<String> names() { if (userExists()) { return Stream.of(readName()).collect(Collectors.toList()); } else { return Collections.emptyList(); }}
prin urmare, am evitat necesitatea ca clientul nostru să efectueze o verificare nulă atunci când apelează această metodă.
folosind obiecte
Java 7 a introdus noul API obiecte. Acest API are mai multe metode de utilitate statice care iau o mulțime de cod redundant. Să ne uităm la o astfel de metodă, requireNonNull ():
public void accept(Object param) { Objects.requireNonNull(param); // doSomething()}
acum, să testăm accept() metoda:
assertThrows(NullPointerException.class, () -> accept(null));
deci, dacă null este trecut ca argument, accept () aruncă o NullPointerException.
această clasă are, de asemenea, metode isNull() și nonNull() care pot fi utilizate ca predicate pentru a verifica un obiect pentru null.
folosind opțional
8.1. Folosind Orelsethrow
Java 8 a introdus un nou API opțional în limba. Acest lucru oferă un contract mai bun pentru manipularea valorilor opționale în comparație cu null. Să vedem cum opțional elimină nevoia de verificări nule:
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; }}
returnând un opțional, așa cum se arată mai sus, metoda de proces face clar apelantului că răspunsul poate fi gol și trebuie manipulat la momentul compilării.
Acest lucru elimină în mod special necesitatea oricăror verificări nule în codul clientului. Un răspuns gol poate fi tratat diferit folosind stilul declarativ al API-ului opțional:
assertThrows(Exception.class, () -> process(false).orElseThrow(() -> new Exception()));
Mai Mult, oferă și un contract mai bun dezvoltatorilor API pentru a semnifica clienților că un API poate returna un răspuns gol.
deși am eliminat necesitatea unei verificări nule a apelantului acestui API, l-am folosit pentru a returna un răspuns gol. Pentru a evita acest lucru, opțional oferă o metodă ofNullable care returnează un opțional cu valoarea specificată, sau gol, dacă valoarea este nulă:
public Optional<Object> process(boolean processed) { String response = doSomething(processed); return Optional.ofNullable(response);}
8.2. Utilizarea opțională cu colecții
în timp ce se ocupă cu colecții goale, opțional vine la îndemână:
public String findFirst() { return getList().stream() .findFirst() .orElse(DEFAULT_VALUE);}
această funcție ar trebui să returneze primul element dintr-o listă. Funcția Findfirst a API-ului Stream va returna un opțional gol atunci când nu există date. Aici, am folosit orElse pentru a oferi o valoare implicită în schimb.
Acest lucru ne permite să gestionăm fie liste goale, fie liste, care după ce am folosit metoda de filtrare a Bibliotecii Stream, nu au elemente de furnizat.
alternativ, putem permite, de asemenea, clientului să decidă cum să se ocupe de gol prin returnarea opțională din această metodă:
public Optional<String> findOptionalFirst() { return getList().stream() .findFirst();}
prin urmare, dacă rezultatul getList este gol, această metodă va returna un opțional gol clientului.
utilizarea opțională cu colecții ne permite să proiectăm API-uri care sunt sigur că returnează valori non-nule, evitând astfel verificările nule explicite ale clientului.
este important să rețineți aici că această implementare se bazează pe getList nu se întoarce null. Cu toate acestea, așa cum am discutat în ultima secțiune, este adesea mai bine să returnați o listă goală decât o nulă.
8.3. Combinarea opțiunilor
când începem să facem funcțiile noastre să revină opțional, avem nevoie de o modalitate de a combina rezultatele lor într-o singură valoare. Să luăm exemplul nostru getList de mai devreme. Ce se întâmplă dacă ar fi să se întoarcă o listă opțională, sau au fost să fie înfășurat cu o metodă care înfășurat un null cu opțional folosind ofNullable?
metoda noastră findFirst dorește să returneze un prim element opțional dintr-o listă opțională:
public Optional<String> optionalListFirst() { return getOptionalList() .flatMap(list -> list.stream().findFirst());}
folosind funcția flatMap pe opțiunea returnată din getOptional putem despacheta rezultatul unei expresii interioare care returnează opțional. Fără flatMap, rezultatul ar fi opțional <opțional<String>>. Operația flatMap este efectuată numai atunci când opțiunea opțională nu este goală.
biblioteci
9.1. Folosind Lombok
Lombok este o bibliotecă mare, care reduce cantitatea de cod boilerplate în proiectele noastre. Vine cu un set de adnotări care iau locul părților comune ale codului pe care le scriem adesea în aplicațiile Java, cum ar fi getters, setters și toString (), pentru a numi câteva.
o altă adnotare este @NonNull. Deci, dacă un proiect utilizează deja Lombok pentru a elimina codul boilerplate, @ NonNull poate înlocui nevoia de verificări nule.
înainte de a merge mai departe pentru a vedea câteva exemple, să adăugăm o dependență Maven pentru Lombok:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.6</version></dependency>
acum, putem folosi @NonNull oriunde este nevoie de o verificare nulă:
public void accept(@NonNull Object param){ System.out.println(param);}
deci, am adnotat pur și simplu obiectul pentru care ar fi fost necesară verificarea nulă și Lombok generează clasa compilate:
public void accept(@NonNull Object param) { if (param == null) { throw new NullPointerException("param"); } else { System.out.println(param); }}
dacă param este null, această metodă aruncă o NullPointerException. Metoda trebuie să facă acest lucru explicit în contractul său, iar codul clientului trebuie să se ocupe de excepție.
9.2. Folosind StringUtils
În general, validarea șir include o verificare pentru o valoare goală în plus față de valoarea nulă. Prin urmare, o declarație comună de validare ar fi:
public void accept(String param){ if (null != param && !param.isEmpty()) System.out.println(param);}
Acest lucru devine rapid redundant dacă avem de-a face cu o mulțime de tipuri de șiruri. Acest lucru este în cazul în care StringUtils vine la îndemână. Înainte de a vedea acest lucru în acțiune, să adăugăm o dependență Maven pentru commons-lang3:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.11</version></dependency>
să refactor acum codul de mai sus cu StringUtils:
public void accept(String param) { if (StringUtils.isNotEmpty(param)) System.out.println(param);}
deci, am înlocuit verificarea nulă sau goală cu o metodă de utilitate statică isNotEmpty(). Acest API oferă alte metode de utilitate puternice pentru manipularea funcțiilor șir comune.
concluzie
în acest articol, ne-am uitat la diferitele motive pentru NullPointerException și de ce este greu de identificat. Apoi, am văzut diverse modalități de a evita redundanța în cod în jurul verificării null cu parametri, tipuri de returnare și alte variabile.
toate exemplele sunt disponibile pe GitHub.
începeți cu Spring 5 și Spring Boot 2, prin cursul Learn Spring:
>> verificați cursul