Java Language
Pułapki Java - Nulls i NullPointerException
Szukaj…
Uwagi
Wartość null
jest wartością domyślną niezainicjowanej wartości pola, którego typ jest typem referencyjnym.
NullPointerException
(lub NPE) to wyjątek NullPointerException
podczas próby wykonania niewłaściwej operacji na odwołaniu do obiektu o null
. Takie operacje obejmują:
- wywołanie metody instancji na
null
obiekcie docelowym, - dostęp do pola
null
obiektu docelowego, - próba indeksowania
null
obiektu tablicowego lub uzyskania dostępu do jego długości, - używając odwołania do obiektu
null
jako muteksu wsynchronized
bloku, - rzutowanie odwołania do obiektu
null
, - rozpakowanie odwołania do
null
obiektu oraz - rzucanie odwołania do obiektu
null
.
Najczęstsze przyczyny pierwotne NPE:
- zapomnienie zainicjować pole z typem referencyjnym,
- zapomnienie zainicjować elementy tablicy typu referencyjnego, lub
- nie testowanie wyników niektórych metod API, które w określonych okolicznościach są zwracane jako
null
.
Przykłady często używanych metod, które zwracają null
obejmują:
- Metoda
get(key)
w interfejsie APIMap
zwrócinull
jeśli wywołasz ją za pomocą klucza, który nie ma mapowania. - Metody
getResource(path)
igetResourceAsStream(path)
w interfejsach APIClassLoader
iClass
zwracająnull
jeśli nie można znaleźć zasobu. - Metoda
get()
wReference
interfejsie API zwrócinull
jeśli moduł czyszczący usunie odwołanie. - Różne metody
getXxxx
w interfejsach API serwletów Java EE zwracająnull
jeśli spróbujesz pobrać nieistniejący parametr żądania, atrybut sesji lub sesji i tak dalej.
Istnieją strategie unikania niechcianych NPE, takie jak jawne testowanie na null
lub używanie „Notacji Yoda”, ale strategie te często powodują niepożądany efekt ukrywania problemów w kodzie, który naprawdę powinien zostać naprawiony.
Pitfall - Niepotrzebne użycie pierwotnych opakowań może prowadzić do wyjątków NullPointerExceptions
Czasami programiści, którzy są nowymi Javami, będą używać zamiennie typów pierwotnych i opakowań. Może to prowadzić do problemów. Rozważ ten przykład:
public class MyRecord {
public int a, b;
public Integer c, d;
}
...
MyRecord record = new MyRecord();
record.a = 1; // OK
record.b = record.b + 1; // OK
record.c = 1; // OK
record.d = record.d + 1; // throws a NullPointerException
Nasza klasa 1 MyRecord
polega na domyślnej inicjalizacji w celu inicjalizacji wartości w swoich polach. Tak więc, gdy mamy new
rekord, gdy a
i b
pola zostaną wyzerowane, a c
i d
pola zostanie ustawiona na null
.
Kiedy próbujemy użyć domyślnych zainicjowanych pól, widzimy, że pola int
działają cały czas, ale pola Integer
działają w niektórych przypadkach, a nie w innych. W szczególności w przypadku, gdy się nie powiedzie (za pomocą d
), zdarza się, że wyrażenie po prawej stronie próbuje rozpakować odwołanie null
, i to powoduje NullPointerException
.
Jest na to kilka sposobów:
Jeśli pola
c
id
konieczności być prymitywne owijarki, wówczas nie powinniśmy polegać na domyślnym inicjalizacji, albo powinny być badania dlanull
. Dla pierwszego jest to poprawne podejście, chyba że istnieje określone znaczenie dla pól w stanienull
.Jeśli pola nie muszą być prymitywnymi opakowaniami, błędem jest uczynienie ich prymitywnymi opakowaniami. Oprócz tego problemu pierwotne opakowania mają dodatkowe koszty ogólne w stosunku do typów pierwotnych.
Lekcja polega na tym, aby nie używać prymitywnych typów opakowań, chyba że naprawdę jest to konieczne.
1 - Ta klasa nie jest przykładem dobrej praktyki kodowania. Na przykład dobrze zaprojektowana klasa nie miałaby pól publicznych. Jednak nie o to chodzi w tym przykładzie.
Pitfall - użycie wartości null do przedstawienia pustej tablicy lub kolekcji
Niektórzy programiści uważają, że dobrym pomysłem jest oszczędzanie miejsca za pomocą null
do przedstawienia pustej tablicy lub kolekcji. Chociaż prawdą jest, że można zaoszczędzić niewielką ilość miejsca, drugą stroną jest to, że sprawia, że kod jest bardziej skomplikowany i delikatny. Porównaj te dwie wersje metody sumowania tablicy:
Pierwsza wersja to sposób, w jaki normalnie kodujesz metodę:
/**
* Sum the values in an array of integers.
* @arg values the array to be summed
* @return the sum
**/
public int sum(int[] values) {
int sum = 0;
for (int value : values) {
sum += value;
}
return sum;
}
Druga wersja to sposób kodowania metody, jeśli masz zwyczaj używania null
do reprezentowania pustej tablicy.
/**
* Sum the values in an array of integers.
* @arg values the array to be summed, or null.
* @return the sum, or zero if the array is null.
**/
public int sum(int[] values) {
int sum = 0;
if (values != null) {
for (int value : values) {
sum += value;
}
}
return sum;
}
Jak widać, kod jest nieco bardziej skomplikowany. Jest to bezpośrednio związane z decyzją o zastosowaniu null
w ten sposób.
Teraz zastanów się, czy ta tablica, która może być null
jest używana w wielu miejscach. W każdym miejscu, w którym go używasz, musisz rozważyć, czy musisz przetestować na null
. Jeśli przegapisz test null
który musi tam być, ryzykujesz NullPointerException
. Dlatego strategia używania null
w ten sposób powoduje, że twoja aplikacja jest bardziej delikatna; tj. bardziej podatny na konsekwencje błędów programisty.
Lekcja polega na tym, aby używać pustych tablic i pustych list, gdy o to ci chodzi.
int[] values = new int[0]; // always empty
List<Integer> list = new ArrayList(); // initially empty
List<Integer> list = Collections.emptyList(); // always empty
Przestrzeń nad głową jest niewielka i istnieją inne sposoby jej zminimalizowania, jeśli jest to warte zachodu.
Pitfall - „Making good” nieoczekiwane wartości zerowe
Na StackOverflow często widzimy taki kod w odpowiedziach:
public String joinStrings(String a, String b) {
if (a == null) {
a = "";
}
if (b == null) {
b = "";
}
return a + ": " + b;
}
Często towarzyszy temu twierdzenie, które jest „najlepszą praktyką” do testowania null
ten sposób, aby uniknąć NullPointerException
.
Czy to najlepsza praktyka? W skrócie: Nie.
Istnieją pewne podstawowe założenia, które należy zakwestionować, zanim będziemy mogli stwierdzić, czy dobrym pomysłem jest to zrobić w naszym joinStrings
:
Co to znaczy, że „a” lub „b” ma wartość zerową?
String
wartość może być zero lub więcej znaków, więc mamy już sposób reprezentowania pusty ciąg. Czy null
oznacza coś innego niż ""
? Jeśli nie, problematyczne są dwa sposoby reprezentowania pustego ciągu.
Czy wartość null pochodzi od niezainicjowanej zmiennej?
null
może pochodzić z niezainicjowanego pola lub niezainicjowanego elementu tablicy. Wartość może być niezainicjowana przez projekt lub przez przypadek. Jeśli był to przypadek, to jest to błąd.
Czy wartość null oznacza „nie wiem” lub „brakującą wartość”?
Czasami null
może mieć prawdziwe znaczenie; np. że rzeczywista wartość zmiennej jest nieznana lub niedostępna lub „opcjonalna”. W Javie 8 klasa Optional
zapewnia lepszy sposób na wyrażenie tego.
Jeśli to błąd (lub błąd projektowy), czy powinniśmy „naprawić”?
Jedną z interpretacji kodu jest to, że „naprawiamy” nieoczekiwany null
, używając pustego ciągu zamiast niego. Czy właściwa strategia? Czy lepiej byłoby pozwolić na NullPointerException
, a następnie uchwycić wyjątek na górze stosu i zarejestrować go jako błąd?
Problem z „naprawianiem” polega na tym, że może albo ukryć problem, albo utrudnić diagnozę.
Czy to jest wydajne / dobre dla jakości kodu?
Jeśli podejście „make good” będzie stosowane konsekwentnie, twój kod będzie zawierał wiele „defensywnych” testów zerowych. Sprawi to, że będzie dłuższy i trudniejszy do odczytania. Co więcej, wszystkie te testy i „poprawianie” mogą mieć wpływ na wydajność aplikacji.
W podsumowaniu
Jeśli null
jest znaczącą wartością, testowanie dla przypadku null
jest poprawnym podejściem. Następstwem jest to, że jeśli wartość null
ma znaczenie, to powinno to być wyraźnie udokumentowane w javadocs każdej metody, która akceptuje lub zwraca wartość null
.
W przeciwnym razie lepszym pomysłem jest potraktowanie nieoczekiwanego null
jako błędu programowego i pozwolenie na NullPointerException
, aby deweloper dowiedział się o problemie w kodzie.
Pitfall - zwracanie wartości null zamiast zgłaszania wyjątku
Niektórzy programiści Java mają ogólną niechęć do zgłaszania lub propagowania wyjątków. Prowadzi to do następującego kodu:
public Reader getReader(String pathname) {
try {
return new BufferedReader(FileReader(pathname));
} catch (IOException ex) {
System.out.println("Open failed: " + ex.getMessage());
return null;
}
}
Jaki jest z tym problem?
Problem polega na tym, że getReader
zwraca null
jako specjalną wartość wskazującą, że nie można otworzyć programu Reader
. Teraz zwrócona wartość musi zostać przetestowana, aby sprawdzić, czy jest null
przed użyciem. Jeśli test zostanie pominięty, wynikiem będzie NullPointerException
.
W rzeczywistości istnieją tutaj trzy problemy:
-
IOException
zostałIOException
zbyt wcześnie. - Struktura tego kodu oznacza, że istnieje ryzyko wycieku zasobu.
- Zwrócono
null
ponieważ żaden „prawdziwy” programReader
był dostępny do zwrócenia.
W rzeczywistości, zakładając, że wyjątek musiał zostać wychwycony tak wcześnie, istnieje kilka alternatyw dla zwrócenia null
:
- Byłoby możliwe zaimplementowanie klasy
NullReader
; np. taki, w którym operacje API zachowują się tak, jakby czytnik był już w pozycji „końca pliku”. - W Javie 8 możliwe jest zadeklarowanie
getReader
jako zwracającegoOptional<Reader>
.
Pitfall - Nie sprawdza, czy strumień I / O nie jest nawet inicjowany podczas zamykania
Aby zapobiec wyciekom pamięci, nie należy zapominać o zamknięciu strumienia wejściowego lub wyjściowego, którego zadanie zostało wykonane. Zwykle odbywa się to za pomocą instrukcji try
- catch
- finally
bez części catch
:
void writeNullBytesToAFile(int count, String filename) throws IOException {
FileOutputStream out = null;
try {
out = new FileOutputStream(filename);
for(; count > 0; count--)
out.write(0);
} finally {
out.close();
}
}
Chociaż powyższy kod może wyglądać niewinnie, ma wadę, która może uniemożliwić debugowanie. Jeśli linia, w której inicjowane jest out
( out = new FileOutputStream(filename)
), zgłasza wyjątek, wówczas out
będzie null
po wykonaniu out.close()
, co NullPointerException
nieprzyjemny NullPointerException
!
Aby temu zapobiec, po prostu upewnij się, że strumień nie ma null
zanim spróbujesz go zamknąć.
void writeNullBytesToAFile(int count, String filename) throws IOException {
FileOutputStream out = null;
try {
out = new FileOutputStream(filename);
for(; count > 0; count--)
out.write(0);
} finally {
if (out != null)
out.close();
}
}
Jeszcze lepszym podejściem jest try
-with-resources, ponieważ automatycznie zamknie strumień z prawdopodobieństwem 0, aby wyrzucić NPE bez konieczności blokowania w finally
.
void writeNullBytesToAFile(int count, String filename) throws IOException {
try (FileOutputStream out = new FileOutputStream(filename)) {
for(; count > 0; count--)
out.write(0);
}
}
Pitfall - Używanie „notacji Yoda”, aby uniknąć wyjątku NullPointerException
Wiele przykładowego kodu opublikowanego na StackOverflow zawiera takie fragmenty:
if ("A".equals(someString)) {
// do something
}
To znaczy „zapobiegać” lub „unikania” ewentualna NullPointerException
w przypadku, someString
jest null
. Ponadto można to stwierdzić
"A".equals(someString)
jest lepszy niż:
someString != null && someString.equals("A")
(Jest bardziej zwięzły, aw niektórych okolicznościach może być bardziej wydajny. Jednak, jak argumentujemy poniżej, zwięzłość może być negatywna).
Jednak prawdziwą pułapką jest użycie testu Yoda, aby uniknąć NullPointerExceptions
jako przyzwyczajenia.
Kiedy piszesz "A".equals(someString)
, tak naprawdę "A".equals(someString)
” przypadek, w którym someString
jest null
. Ale jako kolejny przykład ( Pitfall - „Making good” nieoczekiwane wartości zerowe ) wyjaśnia, że „tworzenie dobrych” wartości null
może być szkodliwe z różnych powodów.
Oznacza to, że warunki Yoda nie są „najlepszą praktyką” 1 . O ile nie spodziewany jest null
, lepiej pozwolić na NullPointerException
, aby uzyskać błąd testu jednostkowego (lub raportu o błędzie). To pozwala ci znaleźć i naprawić błąd, który spowodował pojawienie się nieoczekiwanego / niepożądanego null
.
Warunki Yoda powinny być stosowane tylko w przypadkach, w których oczekiwany jest null
ponieważ testowany obiekt pochodzi z interfejsu API udokumentowanego jako zwracanie null
. I prawdopodobnie lepiej byłoby użyć jednego z mniej ładnych sposobów wyrażania testu, ponieważ pomaga to podświetlić test null
komuś, kto przegląda twój kod.
1 - Według Wikipedii : „Najlepsze praktyki kodowania to zbiór nieformalnych zasad, których społeczność programistów nauczyła się z czasem, co może pomóc w poprawie jakości oprogramowania”. . Używanie notacji Yoda nie osiąga tego. W wielu sytuacjach kod jest gorszy.