Undvik Check for Null-sats i Java
översikt
generellt är nollvariabler, referenser och samlingar svåra att hantera i Java-kod. Inte bara är de svåra att identifiera, men de är också komplexa att hantera.
faktum är att varje miss i hanteringen av null inte kan identifieras vid kompileringstiden och resulterar i en NullPointerException vid körning.
i denna handledning tar vi en titt på behovet av att söka efter null i Java och olika alternativ som hjälper oss att undvika null-kontroller i vår kod.
Further reading:
Using NullAway to Avoid NullPointerExceptions
Spring Null-Safety Annotations
Introduction to the Null Object Pattern
What Is NullPointerException?
enligt Javadoc för NullPointerException kastas den när en applikation försöker använda null i ett fall där ett objekt krävs, till exempel:
- anropar en instansmetod för ett null-objekt
- åtkomst till eller ändring av ett fält av ett null-objekt
- tar längden på null som om det var en array
- åtkomst till eller ändring av slitsarna i null som om det var en array
- kasta null som om det var ett kastbart värde
Låt oss snabbt se några exempel på Java-koden som orsakar detta undantag:
public void doSomething() { String result = doSomethingElse(); if (result.equalsIgnoreCase("Success")) // success }}private String doSomethingElse() { return null;}
Här försökte vi åberopa ett metodanrop för en nollreferens. Detta skulle resultera i en NullPointerException.
ett annat vanligt exempel är om vi försöker komma åt en null-array:
public static void main(String args) { findMax(null);}private static void findMax(int arr) { int max = arr; //check other elements in loop}
detta orsakar en NullPointerException vid rad 6.
således orsakar åtkomst till något fält, metod eller index för ett null-objekt en NullPointerException, vilket framgår av exemplen ovan.
ett vanligt sätt att undvika NullPointerException är att kontrollera efter null:
public void doSomething() { String result = doSomethingElse(); if (result != null && result.equalsIgnoreCase("Success")) { // success } else // failure}private String doSomethingElse() { return null;}
i den verkliga världen har programmerare svårt att identifiera vilka objekt som kan vara null. En aggressivt säker strategi kan vara att kontrollera null för varje objekt. Detta orsakar dock många överflödiga null-kontroller och gör vår kod mindre läsbar.
i de närmaste avsnitten går vi igenom några av alternativen i Java som undviker sådan redundans.
hantering av null genom API-kontraktet
som diskuterats i det sista avsnittet orsakar åtkomst till metoder eller variabler av null-objekt en NullPointerException. Vi diskuterade också att sätta en nollkontroll på ett objekt innan du öppnar det eliminerar möjligheten till NullPointerException.
det finns dock ofta API: er som kan hantera null-värden. Till exempel:
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; }}
utskriftsmetodsamtalet skulle bara skriva ut ”null” men kommer inte att kasta ett undantag. På samma sätt skulle process() aldrig returnera null i sitt svar. Det kastar snarare ett undantag.
så för en klientkod som har åtkomst till ovanstående API: er finns det inget behov av en null-kontroll.
sådana API: er måste dock göra det tydligt i sitt kontrakt. En vanlig plats för API: er att publicera ett sådant kontrakt är JavaDoc.
detta ger dock ingen tydlig indikation på API-kontraktet och förlitar sig därmed på klientkodsutvecklarna för att säkerställa dess överensstämmelse.
i nästa avsnitt ser vi hur några IDE och andra utvecklingsverktyg hjälper utvecklare med detta.
automatisera API-kontrakt
4.1. Använda statisk kodanalys
statiska kodanalysverktyg hjälper till att förbättra kodkvaliteten till en hel del. Och några sådana verktyg tillåter också utvecklarna att behålla nollkontraktet. Ett exempel är FindBugs.
FindBugs hjälper till att hantera null-kontraktet genom anteckningarna @Nullable och @NonNull. Vi kan använda dessa anteckningar över vilken metod, fält, lokal variabel eller parameter som helst. Detta gör det tydligt för klientkoden om den annoterade typen kan vara null eller inte. Låt oss se ett exempel:
public void accept(@Nonnull Object param) { System.out.println(param.toString());}
här gör @NonNull det klart att argumentet inte kan vara null. Om klientkoden anropar denna metod utan att kontrollera argumentet för null, skulle FindBugs generera en varning vid kompileringstiden.
4.2. Använda IDE-stöd
Utvecklare förlitar sig i allmänhet på IDEs för att skriva Java-kod. Och funktioner som smart kodkomplettering och användbara varningar, som när en variabel kanske inte har tilldelats, hjälper verkligen till stor del.
vissa IDE: er tillåter också utvecklare att hantera API-kontrakt och därmed eliminera behovet av ett statiskt kodanalysverktyg. IntelliJ IDEA ger @NonNull och @ Nullable anteckningar. För att lägga till stöd för dessa anteckningar i IntelliJ måste vi lägga till följande Maven-beroende:
<dependency> <groupId>org.jetbrains</groupId> <artifactId>annotations</artifactId> <version>16.0.2</version></dependency>
nu genererar IntelliJ en varning om nollkontrollen saknas, som i vårt senaste exempel.
IntelliJ tillhandahåller också en Kontraktsanteckning för hantering av komplexa API-kontrakt.
5. Påståenden
hittills har vi bara pratat om att ta bort behovet av null-kontroller från klientkoden. Men det är sällan tillämpligt i verkliga applikationer.låt oss nu anta att vi arbetar med ett API som inte kan acceptera null-parametrar eller kan returnera ett null-svar som måste hanteras av klienten. Detta visar behovet av att vi kontrollerar parametrarna eller svaret för ett null-värde.
Här kan vi använda Java-påståenden istället för det traditionella null check villkorliga uttalandet:
public void accept(Object param){ assert param != null; doSomething(param);}
i rad 2 kontrollerar vi efter en null-parameter. Om påståendena är aktiverade skulle detta resultera i ett påstående.
Även om det är ett bra sätt att hävda förutsättningar som icke-null-parametrar, har detta tillvägagångssätt två stora problem:
- påståenden är vanligtvis inaktiverade i en JVM
- ett falskt påstående resulterar i ett okontrollerat fel som är oåterkalleligt
därför rekommenderas det inte för programmerare att använda påståenden för att kontrollera förhållanden. I följande avsnitt kommer vi att diskutera andra sätt att hantera null valideringar.
undvika Nollkontroller genom kodningspraxis
6.1. Förutsättningar
det är vanligtvis en bra praxis att skriva kod som misslyckas tidigt. Därför, om ett API accepterar flera parametrar som inte får vara null, är det bättre att kontrollera för varje icke-null parameter som en förutsättning för API.
låt oss till exempel titta på två metoder – en som misslyckas tidigt och en som inte gör det:
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); }}
klart, vi borde föredra goodAccept() över badAccept().
som ett alternativ kan vi även använda guavas förutsättningar för validering av API-parametrar.
6.2. Använda primitiver istället för Wrapper klasser
eftersom null inte är ett acceptabelt värde för primitiver som int, bör vi föredra dem över deras wrapper motsvarigheter som heltal där det är möjligt.
Tänk på två implementeringar av en metod som summerar två heltal:
public static int primitiveSum(int a, int b) { return a + b;}public static Integer wrapperSum(Integer a, Integer b) { return a + b;}
låt oss nu kalla dessa API: er i vår klientkod:
int sum = primitiveSum(null, 2);
detta skulle resultera i ett kompileringstidfel eftersom null inte är ett giltigt värde för en int.
och när vi använder API med wrapper-klasser får vi en NullPointerException:
assertThrows(NullPointerException.class, () -> wrapperSum(null, 2));
det finns också andra faktorer för att använda primitiver över omslag, som vi täckte i en annan handledning, Java Primitives versus Objects.
6.3. Tomma Samlingar
Ibland måste vi returnera en samling som svar från en metod. För sådana metoder bör vi alltid försöka returnera en tom samling istället för null:
public List<String> names() { if (userExists()) { return Stream.of(readName()).collect(Collectors.toList()); } else { return Collections.emptyList(); }}
därför har vi undvikit behovet av vår klient att utföra en null-kontroll när VI anropar den här metoden.
använda objekt
Java 7 introducerade det nya Objects API. Detta API har flera statiska verktygsmetoder som tar bort mycket överflödig kod. Låt oss titta på en sådan metod, requireNonNull():
public void accept(Object param) { Objects.requireNonNull(param); // doSomething()}
låt oss nu testa accept () – metoden:
assertThrows(NullPointerException.class, () -> accept(null));
så, om null skickas som ett argument, accepterar() en NullPointerException.
denna klass har också isNull() och nonNull() metoder som kan användas som predikat för att kontrollera ett objekt för null.
använda valfritt
8.1. Använda orElseThrow
Java 8 introducerade ett nytt valfritt API på språket. Detta ger ett bättre kontrakt för hantering av valfria värden jämfört med null. Låt oss se hur valfritt tar bort behovet av null-kontroller:
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; }}
genom att returnera ett valfritt, som visas ovan, gör processmetoden det klart för den som ringer att svaret kan vara tomt och måste hanteras vid kompileringstiden.
detta tar särskilt bort behovet av eventuella null-kontroller i klientkoden. Ett tomt svar kan hanteras annorlunda med hjälp av den deklarativa stilen för det valfria API: et:
assertThrows(Exception.class, () -> process(false).orElseThrow(() -> new Exception()));
dessutom ger det också ett bättre kontrakt till API-utvecklare för att indikera för klienterna att ett API kan returnera ett tomt svar.
Även om vi eliminerade behovet av en nollkontroll på den som ringer till detta API, använde vi det för att returnera ett tomt svar. För att undvika detta, tillval ger en ofNullable metod som returnerar en valfri med det angivna värdet, eller tom, om värdet är null:
public Optional<Object> process(boolean processed) { String response = doSomething(processed); return Optional.ofNullable(response);}
8.2. Använda valfritt med Samlingar
När du hanterar tomma samlingar är valfritt praktiskt:
public String findFirst() { return getList().stream() .findFirst() .orElse(DEFAULT_VALUE);}
den här funktionen ska returnera det första objektet i en lista. Stream API: s findFirst-funktion returnerar en tom valfri när det inte finns några data. Här har vi använt orElse för att ge ett standardvärde istället.
detta gör att vi kan hantera antingen tomma listor eller listor, som efter att vi har använt Stream bibliotekets filtermetod, har inga objekt att leverera.
Alternativt kan vi också låta klienten bestämma hur man hanterar tomt genom att returnera valfritt från den här metoden:
public Optional<String> findOptionalFirst() { return getList().stream() .findFirst();}
därför, om resultatet av getList är tomt, kommer den här metoden att returnera en tom valfri till klienten.
genom att använda valfritt med Samlingar kan vi designa API: er som säkert kommer att returnera icke-null-värden, vilket undviker explicita null-kontroller på klienten.
det är viktigt att notera här att denna implementering är beroende av att getList inte returnerar null. Men som vi diskuterade i det sista avsnittet är det ofta bättre att returnera en tom lista snarare än en null.
8.3. Kombinera Optionals
När vi börjar göra våra funktioner tillbaka valfria vi behöver ett sätt att kombinera sina resultat till ett enda värde. Låt oss ta vårt getList-exempel från tidigare. Vad händer om det skulle returnera en valfri lista, eller skulle förpackas med en metod som lindade en null med valfri användning av ofNullable?
vår findFirst-metod vill returnera ett valfritt första element i en valfri lista:
public Optional<String> optionalListFirst() { return getOptionalList() .flatMap(list -> list.stream().findFirst());}
genom att använda flatMap-funktionen på det valfria returnerade från getOptional kan vi packa upp resultatet av ett inre uttryck som returnerar valfritt. Utan flatMap skulle resultatet vara valfritt<valfritt<sträng>>. FlatMap-åtgärden utförs endast när det valfria inte är tomt.
Bibliotek
9.1. Att använda Lombok
Lombok är ett bra bibliotek som minskar mängden standardkod i våra projekt. Den levereras med en uppsättning anteckningar som tar plats för vanliga delar av kod som vi ofta skriver oss i Java-applikationer, till exempel getters, setters och toString (), för att nämna några.
en annan av dess anteckningar är @NonNull. Så om ett projekt redan använder Lombok för att eliminera standardkod, kan @NonNull ersätta behovet av null-kontroller.
innan vi går vidare för att se några exempel, låt oss lägga till ett Maven-beroende för Lombok:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.6</version></dependency>
Nu kan vi använda @NonNull varhelst en null-kontroll behövs:
public void accept(@NonNull Object param){ System.out.println(param);}
Så vi kommenterade helt enkelt objektet för vilket null-kontrollen skulle ha krävts, och Lombok genererar den kompilerade klassen:
public void accept(@NonNull Object param) { if (param == null) { throw new NullPointerException("param"); } else { System.out.println(param); }}
om param är null, kastar denna metod en NullPointerException. Metoden måste göra detta tydligt i sitt kontrakt, och klientkoden måste hantera undantaget.
9.2. Använda StringUtils
generellt innehåller Strängvalidering en kontroll för ett tomt värde utöver null-värde. Därför skulle ett vanligt valideringsuttalande vara:
public void accept(String param){ if (null != param && !param.isEmpty()) System.out.println(param);}
detta blir snabbt överflödigt om vi måste hantera många strängtyper. Det är här StringUtils kommer till hands. Innan vi ser detta i aktion, låt oss lägga till ett Maven-beroende för commons-lang3:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.11</version></dependency>
Låt oss nu refactor ovanstående kod med StringUtils:
public void accept(String param) { if (StringUtils.isNotEmpty(param)) System.out.println(param);}
så ersatte vi vår null eller empty-kontroll med en statisk verktygsmetod isNotEmpty(). Detta API erbjuder andra kraftfulla verktygsmetoder för hantering av vanliga strängfunktioner.
slutsats
i den här artikeln tittade vi på de olika orsakerna till NullPointerException och varför det är svårt att identifiera. Sedan såg vi olika sätt att undvika redundans i kod kring att kontrollera null med parametrar, returtyper och andra variabler.
alla exempel finns på GitHub.
Kom igång med Spring 5 och Spring Boot 2, genom Learn Spring course:
>> kolla in kursen