NullPointerException – Co to jest i jak go uniknąć?

NullPointerException – Co to jest i jak go uniknąć?

W dzisiejszym wpisie przybliżę nieco wyjątek znany każdemu programiście Java, a mianowicie NullPointerException. Mam nadzieję, że gdy już dobrniesz do jego końca, to stanie się dla Ciebie jasne dlaczego ów wyjątek jest tak… wyjątkowy.

Słów kilka o hierarchii wyjątków w Java

Tytułowy NullPointerException jest wyjątkiem języka Java. Dziedziczy on po klasie RuntimeException, co sprawia, że przynależy on do grupy tzw. unchecked exceptions . W przeciwieństwie do wyjątków dziedziczących bezpośrednio z klasy Exception (tzw. checked exceptions) nie ma konieczności jego jawnej obsługi w każdym miejscu, w którym może wystąpić. Na poniższym schemacie hierarchii dziedziczenia wyjątków klasę tę zaznaczono kolorem czerwonym.

Hierarchy of exceptions in Java

NullPointerException stał się obiektem wielu publikacji poruszających tematykę jakości wytwarzanego kodu, co w efekcie przełożyło się na powstanie odpowiednich standardów kodowania, oraz reguł statycznej analizy kodu pozwalających wychwycić potencjalne miejsca jego wystąpienia. Problem ten jest również bardzo poważnie traktowany podczas opracowywania nowych języków programowania. Stosunkowo świeżym przykładem takiego języka może być chociażby Kotlin, w którym został on praktycznie wyeliminowany. Dlaczego zatem NullPointerException jest tak wyjątkowy, aby poświęcać mu tyle uwagi?

Przyczyna występowania NullPointerException

Aby zrozumieć źródło omawianego problemu przyjrzyjmy się procesowi deklarowania zmiennych. Zmienne w Java możemy podzielić na dwie grupy – typy proste oraz obiektowe. Nim przejdziemy dalej rzućmy okiem na poniższy kod.

public class NpeVariableTest {
    public static void main(String[] args) {
        int simpleVar = 10;
        Integer objectVar1 = new Integer(15);
        Integer objectVar2 = null;

        System.out.println("simpleVar:" + simpleVar);
        System.out.println("objectVar1:" + objectVar1.toString());
        System.out.println("objectVar2:" + objectVar2.toString());
    }
}

Na listingu przedstawiono deklarację zamiennej typu prostego simpleVar, której przypisano wartość 10. Dodatkowo możemy również zobaczyć deklarację dwóch zmiennych typu obiektowego – objectVar1, oraz objectVar2. Zatrzymajmy się na chwilę i zastanówmy – czym właściwie różnią się te dwa typy zmiennych?

Zmienne a pamięć

Otóż w przypadku zmiennej prostej alokowana jest pamięć, w której bezpośrednio wstawiona zostaje wartość 10. W przypadku zmiennych typu obiektowego w zmiennej zapisany jest adres (referencja) obszaru pamięci w której znajduje się obiekt. Jeżeli do zmiennej przypiszemy null, oznacza to, że zmienna nie wskazuje na żaden obiekt.

Bezpośrednia przyczyna występowania NullPointerException

Posiadając już podstawową wiedzę o różnicy między typem prostym a obiektem, jesteśmy gotowi aby wrócić do naszego przykładu i poznać co stoi za genezą wyjątków NullPointerException.

public class NpeVariableTest {
    public static void main(String[] args) {
        int simpleVar = 10;
        Integer objectVar1 = new Integer(15);
        Integer objectVar2 = null;

        System.out.println("simpleVar:" + simpleVar);
        System.out.println("objectVar1:" + objectVar1.toString());
        System.out.println("objectVar2:" + objectVar2.toString());
    }
}

Najpierw deklarujemy trzy zmienne, a następnie wyświetlamy ich wartość na standardowe wyjście.  Po uruchomieniu wspomnianego kodu dostaniemy wyjątek NullPointerException w linii 9. Dlaczego? Ponieważ próbowaliśmy wykonać metodę na zmiennej obiektowej, która w nie wskazywała na żaden obiekt (miała wartość null). To najprostsza i zarazem jedyna bezpośrednia przyczyna występowania wyjątku NullPointerException.

Przykłady konstrukcji sprzyjających otrzymaniu NullPointerException

Na poniższym listingu zaprezentowano kilka stosunkowo często stosowanych przez programistów zapisów. Zastanówmy się zatem co jest w nich złego oraz co ewentualnie można by poprawić.

public class NpeConstTest {
    private static String LASTNAME = "CIESLA";

    public static void main(String[] args) {
        String name = null;

        if (name.equals(LASTNAME)) {
            System.out.println("Hello Lukasz");
        }

        if (name.equals("SMITH")) {
            System.out.println("Hello John");
        }
    }
}

Przyrównywanie zmiennych do stałych

Jednym z najczęstszych a zarazem najprostszych do poprawienia błędów jest przyrównywanie zmiennej do stałej zapisując to przyrównanie w postaci:

if (zmienna.equals("STALA"))

W przypadku gdy wartość zmiennej będzie null, to otrzymamy NullPointerException. Jak zatem można poprawić ten zapis? Otóż, wystarczy jedynie zmienić kolejność zapisu, czyli stałą przyrównać do zmiennej. Dzięki temu prostemu zabiegowi, warunek będzie fałszywy w przypadku, gdy zmienna będzie miała wartość null, co prawdopodobnie pokrywa się z intencją programisty, a jednocześnie zapis ten jest odporny na NPE.

if ("STALA".equals(zmienna))

Poniżej przedstawiona została bezpieczna wersja kodu.

public class NpeConstTestFixed {
    private static String LASTNAME = "CIESLA";

    public static void main(String[] args) {
        String name = null;

        if (LASTNAME.equals(name)) {
            System.out.println("Hello Lukasz");
        }

        if ("SMITH".equals(name)) {
            System.out.println("Hello John");
        }
    }
}

Unboxing typów

Dość nieoczywistą konstrukcją, która może doprowadzić do wystąpienia NullPointerException jest tzw. unboxing. Jest to automatyczne odpakowanie typu obiektowego na odpowiadający mu typ prosty np. Integer na int. Na poniższym listingu zaprezentowano przykład, w którym w linii 8 otrzymamy NPE. Prześledźmy zatem mechanizm jego wystąpienia.

public class NpeUnboxingTest {
    
    private static Integer getInteger() {
        return null;
    }

    public static void main(String[] args) {
        int simpleVar = getInteger();
        System.out.println("simpleVar:" + simpleVar);
    }
}

Metoda getInteger() zwraca obiekt typu Integer (w naszym przykładzie zawsze null). W linii 8 ma miejsce wywołanie metody getInteger() a następnie unboxing zwracanej wartości do typu prostego int. W przypadku unboxingu wartości null otrzymujemy wyjątek NullPointerException.

Metoda getInteger() zwraca obiekt typu Integer (w naszym przykładzie zawsze null). W linii 8 ma miejsce wywołanie metody getInteger() a następnie unboxing zwracanej wartości do typu prostego int. W przypadku unboxingu wartości null otrzymujemy wyjątek NullPointerException.

Ciekawą wariacją prowadzącą do niebezpiecznego kodu jest połączenie unboxingu z wyrażeniem warunkowym. Na poniższym listingu przyrównano obiektowy wynik metody getMaxAge() do stałej o wartości 30, co w przypadku zwrócenia null doprowadzi do wyrzucenia wyjątku. Jak zatem zabezpieczyć się w takiej sytuacji?

public class NpeUnboxingCondTest {
    private static Integer getMaxAge() {
        return null;
    }

    public static void main(String[] args) {
        if (getMaxAge() == 30) {
            System.out.println("You are 30");
        }
    }
}

W tym konkretnym przypadku wystarczającym zabiegiem, będzie wyniesienie wartości 30 do stałej i odwrócenie przyrównania.

public class NpeUnboxingCondTestFixed {
    public static final Integer SPECIAL_AGE = 30;

    private static Integer getMaxAge() {
        return null;
    }

    public static void main(String[] args) {
        if (SPECIAL_AGE.equals(getMaxAge())) {
            System.out.println("You are 30");
        }
    }
}

Operacje na obiektach zwracanych przez metody typu find

Dość częstym przypadkiem, z którym programiści mają do czynienia jest wyszukanie jakiegoś obiektu np. w bazie danych i ewentualna operacja na nim. Na poniższym listingu zasymulowano opisaną sytuację. Po uruchomieniu programu otrzymamy NullPointerException w linii 8, ponieważ próbujemy wykonać metodę toString() na zmiennej, która nie wskazuje na żaden obiekt.

public class NpeFindTest {
    private static Object findObject() {
        return null;
    }

    public static void main(String[] args) {
        Object o = findObject();
        System.out.println("I'm: " + o.toString());
    }
}

Powyższy przykład jest bardzo podobny jak omawiany wcześniej w sekcji dotyczącej unboxingu. Zdecydowałem się jednak poświęcić mu osobny punkt, ponieważ samo zagadnienie było na tyle częste i problematyczne, że doczekało się eleganckiego rozwiązania.

Najprostszym rozwiązaniem jest oczywiście sprawdzenie czy metoda findObject() zwróciła jakiś obiekt i dopiero wtedy wykonanie na nim właściwej operacji.

public class NpeFindTestFixed {
    private static Object findObject() {
        return null;
    }

    public static void main(String[] args) {
        Object o = findObject();
        if (o != null) {
            System.out.println("I'm: " + o.toString());
        }
    }
}

Mimo, że przedstawione rozwiązanie zabezpiecza przed otrzymaniem wyjątku, to zmniejsza czytelność kodu. Mam tu na myśli to, że piszemy “jeśli wartość zmiennej jest różna od null” a czytamy “jeśli obiekt istnieje, to wykonaj…”. Dlaczego zatem nie zapisać wprost naszej intencji?

Począwszy od Java 8, a wcześniej również w bibliotece Google Guava, mamy dostępne od ręki eleganckie narzędzie, które idealnie sprawdza się w tego typu przypadkach. Mam tu na myśli Optional. Poniżej zaprezentowany został kod, w którym połączono Optional z wprowadzonymi również w JDK 8 wyrażeniami lambda. Konstrukcja ta znacząco skraca zapis przy jednoczesnej poprawie czytelności oraz zabezpieczeniu przed NullPointerException.

public class NpeFindTestOptionalFixed {
    private static Optional<Object> findObject() {
        return Optional.ofNullable(null);
    }

    public static void main(String[] args) {
        findObject().ifPresent(object -> System.out.println("I'm: " + object.toString()));
    }
}

Operacje na tablicach

Ostatnim już przypadkiem, który zdecydowałem się poruszyć jest błedna inicjalizacja tablic w Java, która może skutkować otrzymaniem wyjątku NPE. Zobaczmy zatem na poniższy przykład.

public class NpeArrayTest {
    private static class Car {
        public void drive() {
            System.out.println("Brumm....");
        }
    }

    public static void main(String[] args) {
        Car[] cars = new Car[5];
        cars[0].drive();
    }
}

Po uruchomieniu programu w 10 linii otrzymamy wyjątek NullPointerException. Spowodowane jest to tym, że w linii 9 zadeklarowaliśmy tablicę w której możemy przechowywać 5 referencji do obiektów typu samochód, co nie jest równoznaczne z utworzeniem 5 samochodów. Po takiej deklaracji wszystkie referencje ustawione są na null. Aby wypełnić tę tablicę samochodami należy przeiterować po niej tworząc i przypisując samochód do każdego z elementów. Poniżej zaprezentowana została poprawiona postać kodu, który poprawnie inicjalizuję tablicę obiektów.

public class NpeArrayTestFixed {
    private static class Car {
        public void drive() {
            System.out.println("Brumm....");
        }
    }

    public static void main(String[] args) {
        Car[] cars = new Car[5];
        for (int i = 0; i < cars.length; i++) {
            cars[i] = new Car();
        }
        cars[0].drive();
    }
}

To byłoby na tyle jeśli chodzi o ten wpis. Więcej artykułów dostępnych jest w  angielskiej wersji bloga Jeśli podoba Ci się konwencja i chciałbyś więcej takich wpisów, zostaw mi komentarz z tematem, który Cię interesuje a ja postaram się go przygotować. Tymczasem możesz mi pomóc dotrzeć do większej liczby odbiorców udostępniając ten wpis.
Dziękuję

1
Leave a Reply

avatar
1 Comment threads
0 Thread replies
0 Followers
 
Most reacted comment
Hottest comment thread
1 Comment authors
Marzena Recent comment authors
  Subscribe  
newest oldest most voted
Notify of
Marzena
Guest

Bardzo jasno to wytłumaczyłeś, dzięki! Następnym razem jak mi wyskoczy NPE, będę wracała do Twojego artykułu 🙂

Close Menu