unikaj sprawdzania instrukcji Null w Javie
przegląd
ogólnie, zmienne null, odwołania i kolekcje są trudne w obsłudze w kodzie Javy. Nie tylko są trudne do zidentyfikowania, ale są również skomplikowane w radzeniu sobie z nimi.
w rzeczywistości, wszelkie pominięcia w radzeniu sobie z null nie mogą być zidentyfikowane w czasie kompilacji i skutkuje wystąpieniem wyjątku NullPointerException w czasie wykonywania.
w tym samouczku przyjrzymy się potrzebie sprawdzenia null w Javie i różnych alternatyw, które pomagają nam uniknąć sprawdzania null w naszym kodzie.
Further reading:
Using NullAway to Avoid NullPointerExceptions
Spring Null-Safety Annotations
Introduction to the Null Object Pattern
What Is NullPointerException?
zgodnie z Javadoc dla wyjątku NullPointerException, jest on wyrzucany, gdy aplikacja próbuje użyć null W przypadku, gdy obiekt jest wymagany, na przykład:
- wywołanie metody instancji obiektu null
- dostęp do lub modyfikowanie pola obiektu null
- przyjmowanie długości null tak, jakby to była tablica
- dostęp do lub modyfikowanie slotów null tak, jakby to była tablica
- wyrzucenie null tak, jakby to była wartość do wyrzucenia
szybko zobaczmy kilka przykładów kodu Javy, który powoduje ten wyjątek:
public void doSomething() { String result = doSomethingElse(); if (result.equalsIgnoreCase("Success")) // success }}private String doSomethingElse() { return null;}
tutaj próbowaliśmy wywołać wywołanie metody dla odniesienia null. Spowoduje to wystąpienie wyjątku NullPointerException.
innym typowym przykładem jest próba dostępu do tablicy null:
public static void main(String args) { findMax(null);}private static void findMax(int arr) { int max = arr; //check other elements in loop}
powoduje to wystąpienie wyjątku NullPointerException w linii 6.
tak więc, dostęp do dowolnego pola, metody lub indeksu obiektu null powoduje wyjątek NullPointerException, jak widać z powyższych przykładów.
powszechnym sposobem na uniknięcie wyjątku NullPointerException jest sprawdzenie null:
public void doSomething() { String result = doSomethingElse(); if (result != null && result.equalsIgnoreCase("Success")) { // success } else // failure}private String doSomethingElse() { return null;}
w prawdziwym świecie programiści mają trudności z identyfikacją, które obiekty mogą być null. Agresywnie bezpieczną strategią może być sprawdzenie null dla każdego obiektu. Powoduje to jednak wiele zbędnych sprawdzeń null i sprawia, że nasz kod jest mniej czytelny.
w następnych sekcjach omówimy niektóre alternatywy w Javie, które unikają takiej redundancji.
Obsługa null poprzez kontrakt API
jak omówiono w ostatniej sekcji, dostęp do metod lub zmiennych obiektów null powoduje wystąpienie wyjątku NullPointerException. Omówiliśmy również, że umieszczenie sprawdzenia null na obiekcie przed uzyskaniem do niego dostępu eliminuje możliwość wystąpienia wyjątku NullPointerException.
jednak często istnieją interfejsy API, które mogą obsługiwać wartości null. Na przykład:
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; }}
wywołanie metody print() po prostu wyświetli „null”, ale nie wyrzuci wyjątku. Podobnie, process() nigdy nie zwróci null w swojej odpowiedzi. To raczej rzuca wyjątek.
więc dla kodu klienta uzyskującego dostęp do powyższych API, nie ma potrzeby sprawdzania null.
jednak takie interfejsy API muszą wyraźnie zaznaczyć to w swojej umowie. Powszechnym miejscem dla API do publikowania takiej umowy jest JavaDoc.
to jednak nie daje wyraźnego wskazania umowy API i dlatego zależy od programistów kodu klienta, aby zapewnić jego zgodność.
w następnej sekcji zobaczymy, jak kilka IDE i innych narzędzi programistycznych pomaga programistom w tym.
Automatyzacja umów API
4.1. Korzystanie ze statycznej analizy kodu
narzędzia do statycznej analizy kodu pomagają znacznie poprawić jakość kodu. A kilka takich narzędzi pozwala również deweloperom na utrzymanie zerowej umowy. Jednym z przykładów jest FindBugs.
FindBugs pomaga zarządzać kontraktem null za pomocą adnotacji @Nullable i @nonnull. Możemy użyć tych adnotacji nad dowolną metodą, polem, zmienną lokalną lub parametrem. To sprawia, że jawne dla kodu klienta, czy adnotowany Typ może być null, czy nie. Zobaczmy przykład:
public void accept(@Nonnull Object param) { System.out.println(param.toString());}
tutaj @NonNull wyjaśnia, że argument nie może być null. Jeśli kod klienta wywoła tę metodę bez sprawdzania argumentu null, FindBugs wygeneruje ostrzeżenie podczas kompilacji.
4.2. Korzystanie ze wsparcia IDE
Programiści zazwyczaj polegają na IDE do pisania kodu Java. Funkcje takie jak inteligentne uzupełnianie kodu i użyteczne Ostrzeżenia, takie jak przypisanie zmiennej, z pewnością pomagają w dużym stopniu.
niektóre IDE umożliwiają również programistom zarządzanie umowami API, eliminując tym samym potrzebę statycznego narzędzia do analizy kodu. IntelliJ IDEA udostępnia adnotacje @NonNull i @Nullable. Aby dodać obsługę tych adnotacji w IntelliJ, musimy dodać następującą zależność Mavena:
<dependency> <groupId>org.jetbrains</groupId> <artifactId>annotations</artifactId> <version>16.0.2</version></dependency>
teraz IntelliJ wygeneruje ostrzeżenie, jeśli nie ma sprawdzenia null, jak w naszym ostatnim przykładzie.
IntelliJ zapewnia również adnotację umowy do obsługi złożonych kontraktów API.
5. Asercje
do tej pory mówiliśmy tylko o usunięciu konieczności sprawdzania null z kodu klienta. Ale rzadko ma to zastosowanie w rzeczywistych zastosowaniach.
Załóżmy teraz, że pracujemy z API, które nie może zaakceptować parametrów null lub może zwrócić odpowiedź null, która musi być obsługiwana przez Klienta. Oznacza to, że musimy sprawdzić parametry lub odpowiedź na wartość null.
tutaj możemy użyć asercji Java zamiast tradycyjnej instrukcji warunkowej null check:
public void accept(Object param){ assert param != null; doSomething(param);}
w wierszu 2 sprawdzamy, czy nie ma parametru null. Jeśli assertions są włączone, spowoduje to wystąpienie błędu Asertionerror.
chociaż jest to dobry sposób na potwierdzenie warunków wstępnych, takich jak parametry inne niż null, takie podejście ma dwa poważne problemy:
- Assertions are usually disabled in a JVM
- false assertion results in an unchecked error that is irreverable
therefore, it is not recommended for programmers to use Assertions for checking conditions. W kolejnych sekcjach omówimy inne sposoby obsługi walidacji null.
unikanie sprawdzania Null poprzez praktyki kodowania
6.1. Warunki wstępne
Zwykle dobrą praktyką jest pisanie kodu, który zawodzi wcześniej. Dlatego, jeśli API akceptuje wiele parametrów, które nie mogą być równe null, lepiej jest sprawdzić każdy parametr inny niż null jako warunek wstępny API.
przyjrzyjmy się na przykład dwóm metodom – jednej, która zawodzi wcześnie, a drugiej, która nie:
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); }}
wyraźnie powinniśmy preferować goodAccept() zamiast badAccept().
jako alternatywę możemy również użyć warunków wstępnych Guava do walidacji parametrów API.
6.2. Używanie klas podstawowych zamiast klas Wrapper
ponieważ null nie jest akceptowalną wartością dla klas podstawowych, takich jak int, powinniśmy preferować je zamiast ich odpowiedników wrapper, takich jak Integer, o ile to możliwe.
rozważmy dwie implementacje metody sumującej dwie liczby całkowite:
public static int primitiveSum(int a, int b) { return a + b;}public static Integer wrapperSum(Integer a, Integer b) { return a + b;}
teraz nazwijmy te API w kodzie naszego klienta:
int sum = primitiveSum(null, 2);
spowoduje to błąd podczas kompilacji, ponieważ null nie jest poprawną wartością dla int.
i podczas korzystania z API z klasami wrapper otrzymujemy wyjątek NullPointerException:
assertThrows(NullPointerException.class, () -> wrapperSum(null, 2));
istnieją również inne czynniki wpływające na używanie prymitywów nad opakowaniami, jak opisaliśmy w innym samouczku, prymitywy Javy kontra Obiekty.
Puste Kolekcje
czasami musimy zwrócić kolekcję jako odpowiedź metody. W przypadku takich metod zawsze powinniśmy zwracać pustą kolekcję zamiast null:
public List<String> names() { if (userExists()) { return Stream.of(readName()).collect(Collectors.toList()); } else { return Collections.emptyList(); }}
dlatego uniknęliśmy potrzeby, aby nasz klient wykonywał sprawdzenie null podczas wywoływania tej metody.
używanie obiektów
Java 7 wprowadziła nowe Api obiektów. To API ma kilka statycznych metod użytkowych, które zabierają dużo nadmiarowego kodu. Spójrzmy na jedną z takich metod, requireNonNull ():
public void accept(Object param) { Objects.requireNonNull(param); // doSomething()}
teraz przetestujmy metodę accept ():
assertThrows(NullPointerException.class, () -> accept(null));
więc, jeśli null zostanie przekazany jako argument, accept () wyrzuci wyjątek NullPointerException.
Ta klasa posiada również metody isNull() i nonnull (), które mogą być użyte jako predykaty do sprawdzania obiektu pod kątem null.
używając opcji
8.1. Używając orElseThrow
Java 8 wprowadziła nowe opcjonalne API w tym języku. Oferuje to lepszą umowę obsługi wartości opcjonalnych w porównaniu do wartości null. Zobaczmy, jak opcjonalne usuwa potrzebę sprawdzania 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; }}
zwracając opcjonalne, jak pokazano powyżej, metoda procesu wyjaśnia wywołującemu, że odpowiedź może być pusta i musi być obsługiwana podczas kompilacji.
to w szczególności eliminuje potrzebę sprawdzania null w kodzie klienta. Pusta odpowiedź może być obsługiwana w różny sposób przy użyciu deklaratywnego stylu opcjonalnego API:
assertThrows(Exception.class, () -> process(false).orElseThrow(() -> new Exception()));
ponadto zapewnia lepszą umowę dla programistów API, aby pokazać klientom, że API może zwrócić pustą odpowiedź.
chociaż wyeliminowaliśmy potrzebę sprawdzania null wywołującego to API, użyliśmy go do zwrócenia pustej odpowiedzi. Aby tego uniknąć, opcjonalne dostarcza metodę ofNullable, która zwraca Opcjonalne z podaną wartością, lub puste, jeśli wartość jest null:
public Optional<Object> process(boolean processed) { String response = doSomething(processed); return Optional.ofNullable(response);}
8.2. Używanie opcji z kolekcjami
podczas gdy zajmowanie się pustymi kolekcjami, opcjonalne przydaje się:
public String findFirst() { return getList().stream() .findFirst() .orElse(DEFAULT_VALUE);}
Ta funkcja powinna zwracać pierwszy element listy. Funkcja Findfirst API strumienia zwróci puste opcjonalne, gdy nie ma danych. Zamiast tego użyliśmy orElse do podania wartości domyślnej.
pozwala nam to na obsługę pustych list, lub list, które po użyciu metody filtrowania Biblioteki strumieniowej nie mają żadnych elementów do dostarczenia.
alternatywnie, możemy również pozwolić klientowi zdecydować, jak obsługiwać puste, zwracając Opcjonalne z tej metody:
public Optional<String> findOptionalFirst() { return getList().stream() .findFirst();}
dlatego, jeśli wynik getList jest pusty, ta metoda zwróci Klientowi pusty opcjonalny.
używanie opcjonalnych z kolekcjami pozwala nam zaprojektować API, które z pewnością zwracają wartości inne niż null, unikając w ten sposób jawnego sprawdzania null na kliencie.
należy zauważyć, że ta implementacja polega na tym, że getList nie zwraca null. Jednak, jak omówiliśmy w ostatniej sekcji, często lepiej jest zwrócić pustą listę, a nie null.
Łączenie opcji
kiedy zaczynamy zwracać funkcje opcjonalne, potrzebujemy sposobu na połączenie ich wyników w jedną wartość. Weźmy przykład getList z poprzedniego. Co by było, gdyby zwracała listę opcjonalną lub była opakowana metodą, która opakowała null z opcjonalnym użyciem ofNullable?
nasza metoda findFirst chce zwrócić opcjonalny pierwszy element listy opcjonalnej:
public Optional<String> optionalListFirst() { return getOptionalList() .flatMap(list -> list.stream().findFirst());}
używając funkcji flatMap na opcjonalnym zwróconym z getOptional możemy rozpakować wynik wewnętrznego wyrażenia, które zwraca opcjonalne. Bez flatMap wynik byłby opcjonalny<opcjonalny<String>>. Operacja flatMap jest wykonywana tylko wtedy, gdy opcja nie jest pusta.
Biblioteki
9.1. Korzystanie z Lombok
Lombok jest świetną biblioteką, która zmniejsza ilość kodu boilerplate w naszych projektach. Zawiera zestaw adnotacji, które zastępują wspólne części kodu, które często sami piszemy w aplikacjach Java, takich jak getters, setters i ToString(), by wymienić tylko kilka.
kolejna jego adnotacja to @NonNull. Tak więc, jeśli projekt używa już Lombok do wyeliminowania kodu boilerplate, @NonNull może zastąpić potrzebę sprawdzania null.
zanim przejdziemy do kilku przykładów, dodajmy zależność Mavena dla Lombok:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.6</version></dependency>
teraz możemy użyć @NonNull wszędzie tam, gdzie potrzebne jest sprawdzenie null:
public void accept(@NonNull Object param){ System.out.println(param);}
więc po prostu przypisaliśmy obiekt, dla którego wymagane byłoby sprawdzenie null, i Lombok generuje skompilowaną klasę:
public void accept(@NonNull Object param) { if (param == null) { throw new NullPointerException("param"); } else { System.out.println(param); }}
Jeśli parametr param ma wartość null, ta metoda zgłasza wyjątek NullPointerException. Metoda musi to wyraźnie zaznaczyć w swojej umowie, a kod klienta musi obsługiwać wyjątek.
9.2. Używanie StringUtils
Ogólnie Rzecz Biorąc, Walidacja łańcuchów zawiera sprawdzenie pustej wartości oprócz wartości null. W związku z tym powszechnym oświadczeniem walidacyjnym byłoby:
public void accept(String param){ if (null != param && !param.isEmpty()) System.out.println(param);}
to szybko staje się zbędne, jeśli mamy do czynienia z wieloma typami łańcuchów. Tu przydają się StringUtils. Zanim zobaczymy to w akcji, dodajmy zależność Mavena dla commons-lang3:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.11</version></dependency>
przepakujmy teraz powyższy kod za pomocą StringUtils:
public void accept(String param) { if (StringUtils.isNotEmpty(param)) System.out.println(param);}
w związku z tym zastąpiliśmy nasze sprawdzenie null lub empty statyczną metodą isnotempty(). Ten interfejs API oferuje inne potężne metody narzędzi do obsługi typowych funkcji łańcuchowych.
wnioski
w tym artykule przyjrzeliśmy się różnym powodom wystąpienia NullPointerException i tym, dlaczego jest on trudny do zidentyfikowania. Następnie widzieliśmy różne sposoby uniknięcia redundancji w kodzie, polegającej na sprawdzaniu null za pomocą parametrów, typów zwracanych i innych zmiennych.
wszystkie przykłady są dostępne na Githubie.
zacznij od Spring 5 i Spring Boot 2, Poprzez kurs Learn Spring:
>>sprawdź kurs