NullPointerException, czyli nie rób drugiemu co Tobie nie miłe

Ile razy Twoja aplikacja skończyła swój żywot przez rzucenie wyjątku NullPointerException? Moja niestety zbyt wiele… Niezależnie od tego czy był to projekt hobbistyczny czy komercyjny to i tak napotykałem ten sam komunikat „Exception in thread 'main' java.lang.NullPointerException”. Jeżeli piszesz w Javie to dobrze znasz ten błąd. Jest to nic innego jak konsekwencja wykonywania operacji na braku wartości, czyli tzw. null. Ta przypadłość w głównej mierze dotyczy języków silnie typowanych. Możesz teraz sobie zadać pytanie skąd w ogóle wziął się null? Za jego ojca uznaje się Tonego Hoare, który w 1965r. zaimplementował właśnie tą wartość na potrzeby języka Algol. Jednak po 40 latach sam autor przyznał, że null stał się wielką pomyłką kosztującą wiele miliardów dolarów. Wracając do tematu tego artykułu to chciałbym zacząć od wyjaśnienia czym w ogóle jest NullPointerException.

NullPointerException w Javie

W Javie istnieje podział na dwa typu wyjątków: checked i unchecked. Wyjątki z grupy checked muszą zostać obsłużone w miejscu, gdzie istnieje możliwość ich rzucenia. W innym wypadku nasz kod się nie skompiluje. Istnieją dwie możliwości obsługi takiego wyjątku:

  • dzięki wykorzystaniu bloku try-catch
  • przez deklarację throws w sygnaturze metody
import java.io.IOException;

public class ExceptionHandler {

    public void useTryCatchBlock() {
        try {
            throw new IOException("Exception has been thrown!");
        } catch (Exception e) {
            System.out.println("Caught exception with message: " + e.getMessage());
        }
    }
    
    public void passThrownException() throws IOException {
        throw new IOException("Exception has been thrown again!");
    }
}

Który sposób jest lepszy oraz w jaki sposób można je wykorzystać w implementacji oprogramowania to temat na osobny artykuł. Drugą grupę natomiast stanowią wyjątki typu unchecked, do których zalicza się właśnie NullPointerException. Ten typ z kolei, jak można się domyślić, nie potrzebuje, aby go obsłużyć w miejscu wystąpienia.

public class ExceptionCreator {

    public void throwNullPointerException() {
        throw new NullPointerException("I am evil NullPointerException!");
    }
}

Wyjątki z grupy checked tworzymy poprzez rozszerzenie klasy java.lang.Exception, natomiast typu unchecked dzięki wykorzystaniu java.lang.RuntimeException. Lepiej można to dostrzec na poniższym obrazku zawierającym hierarchię wyjątków w Javie. Dodam, że istnieje też klasa java.lang.Error, która reprezentuje błędy systemowe spowodowane np. brakiem pamięci. Ich natomiast nie należy ani rzucać ani obsługiwać czy też rozszerzać, są one zarezerwowane wyłącznie dla systemu.

Hierarchia wyjątków w Javie (https://codegym.cc/groups/posts/exceptions-in-java)

Skoro już mamy teorię za sobą czas na część praktyczną! Przyjrzyjmy się zatem w jaki sposób dochodzi w ogóle do sytuacji, w których rzucany jest wyjątek NullPointerException. Następnie spróbujemy uporać się z tym problemem, aby nie robić nikomu przykrych niespodzianek (zwłaszcza sobie w przyszłości!).

Kod skazany na NullPointerException

Najprostszym możliwym sposobem jest przypisanie wartości null do referencji wybranej klasy, a następnie wywołanie na niej dowolnej metody bądź też wyciągnięcia z niej wartości zmiennej. Przepis na katastrofę gwarantowany!

public class NullPointerExceptionExample {

    public static void invokeNullPointerException() {
        ExampleClass example = null;
        
        System.out.println(example.invokeMethod());
        System.out.println(example.value);
    }

    public static void main(String[] args) {
        invokeNullPointerException();
    }
}

class ExampleClass {
    
    int value = 10;   
    
    public String invokeMethod() {
        return "Some value returned";
    }
}

Bardziej wyrafinowanym sposobem na wywołanie NullPointerException jest napisanie kodu, który wykorzystuje unboxing typów prymitywnych. Załóżmy, że metoda przyjmuje parametr typu Integer, który następnie przypisywany jest do zmiennej int. Jeżeli na wejściu dostaniemy wartość null i zostanie ona przypisana właśnie do typu prymitywnego to nie otrzymamy np. zera tylko… NullPointerException!

public class UnboxingExceptionExample {

    public static void assignIntegerToInt(Integer value) {
        int newValue = value;
    }

    public static void main(String[] args) {
        assignIntegerToInt(null);
    }
}

Na sam koniec zostawiłem wisienkę na torcie, którą usłyszałem kiedyś na jednym z wykładów Sławka Sobótki. Chodzi tutaj o złamanie prawa Demeter w przykładzie z człowiekiem. Jeżeli chcemy kogoś nakarmić to powinniśmy dać wybranej osobie np. kanapkę. Następnie rozpocznie ona po prostu jej konsumpcje. Implementacja tej sytuacji mogłaby wyglądać następująco.

public class PersonFeeder {
    
    private Person personToFeed = new Person();
    
    public void feedPerson(Sandwich sandwich) {
        personToFeed.eat(sandwich);
    }
}

Jako, że naszym celem jest tylko nakarmienie osoby to nie obchodzi nas w jaki sposób ona skonsumuje tą kanapkę. Natomiast łamiąc wcześniej wspomnianą zasadę Demeter dochodzimy do absurdalnego kodu. Zobaczcie sami.

public class PersonFeeder {

    private Person personToFeed = new Person();

    public void feedPerson(Sandwich sandwich) {
        personToFeed.getStomach().getGastricAcid().setSandwich(sandwich);
    }
}

Prawda, że to nieciekawa sytuacja i wręcz niemożliwa do wyobrażenia? Często widuję taki kod, ale nie w tym rzecz. Co by się stało, gdyby człowiek nie miał w konkretnej sytuacji kwasów trawiennych? Znowu wjeżdża nam cały na biało, NullPointerException! Jest to naprawdę ciężkie do prześledzenia, która dokładnie metoda zwróciła nam null, ale jak wspomniał Piotr Przybył na WJUGu od Javy 15 uległo to zmianie.

No dobrze, ale jak wystrzegać się tego typu sytuacji, aby nie utrudniać innym życia? Jest kilka sposób, które zamierzam teraz Wam przytoczyć.

Wykorzystanie warunku if

Najprostszym i za pewne powszechnie stosowanym sposobem jest po prostu użycie instrukcji warunkowej if. Jeżeli wywołamy daną metodę i zwróci nam ona rezultat to powinniśmy sprawdzić czy nie otrzymaliśmy przypadkiem nulla. Jednak jest to niepolecany sposób radzenia sobie z brakiem wartości. Możemy doprowadzić do następującej sytuacji.

public class NumberOperator {

    private NumberService numberService;

    public NumberOperator(NumberService numberService) {
        this.numberService = numberService;
    }

    public Integer firstMethod() {
        Integer number = secondMethod();
        if (number != null) {
            // do some operation on number
            return number;
        }
        return null;
    }

    private Integer secondMethod() {
        Integer number = thirdMethod();
        if (number != null) {
            // do some operation on number
            return number;
        }
        return null;
    }

    private Integer thirdMethod() {
        Integer number = numberService.randomizeNumber();
        if (number != null) {
            // do some operation on number
            return number;
        }
        return null;        
    }
}

Nasze trzy metody są uzależnione od tego, aby weryfikować czy nie otrzymały null od innych metod. Ten sposób zaciemnia nam obraz naszego kodu pod kątem biznesowym przez dbanie na każdym poziomie o szczegóły techniczne. Może lepszą metodą jest… rzucanie nowych wyjątków? Sprawdźmy.

public class NumberOperator {

    private NumberService numberService;

    public NumberOperator(NumberService numberService) {
        this.numberService = numberService;
    }

    public Integer firstMethod() {
        Integer number = secondMethod();
        // do some operation on number
        return number;
    }

    private Integer secondMethod() {
        Integer number = thirdMethod();
        // do some operation on number
        return number;
    }

    private Integer thirdMethod() {
        Integer number = numberService.randomizeNumber();
        if (number != null) {
            // do some operation on number
            return number;
        }
        throw new NotFoundIntegerException();
    }
}

Wygląda to lepiej, ale nie jest to do końca idealne rozwiązanie. Na pewno dostajemy precyzyjniejszą informację zwrotną o nieznalezionym numerze zamiast nic nie mówiącą informację o braku wartości. Tylko teraz jesteśmy zależni od szczegółów implementacyjnych. Nie każdy musi wiedzieć, że rzucamy wyjątek jeżeli nie znajdziemy numeru, więc dalej może próbować wstawiać warunki if. Co z tym możemy zrobić? Wykorzystać Optional dostarczony wraz z Javą 8!

public class NumberOperator {

    private NumberService numberService;

    public NumberOperator(NumberService numberService) {
        this.numberService = numberService;
    }

    public Integer firstMethod() {
        Optional<Integer> number = secondMethod();
        return number.orElse(new Random().nextInt(100));
    }

    private Optional<Integer> secondMethod() {
        Optional<Integer> number = thirdMethod();
        return number.map(... some operations ... );
    }

    private Optional<Integer> thirdMethod() {
        Optional<Integer> number = numberService.randomizeNumber();
        return number.map(... some operations ... );
    }
}

Zdecydowanie to rozwiązanie wygrywa! Jeżeli numberService.randomizeNumber() zwróci nam Optional.empty() to żadna z powyższych instrukcji w map się nie wywoła. Tylko na koniec w metodzie firstMethod zostanie wylosowana wartość od 0 do 99. Nie musimy się tutaj martwić o NullPointerException.

Na koniec chciałem pokazać coś ekstra o czym wspomniał Jarek Ratajski w swoim wykładzie o monadach. Zawsze może trafić się ktoś kto nas strolluje. W jaki sposób? A no w taki!

Optional<Integer> number = null;

Jeżeli, więc pracujesz w firmie, która pisze taki kod to może warto zastanowić się nad zmianą pracy 😉.

Podsumowanie

Nikt z nas nie lubi, gdy jego aplikacja kończy swoje działanie w sposób niemożliwy do przewidzenia, a zwłaszcza przez taki wyjątek jak NullPointerException. Java pod tym kątem się raczej nie zmieni przez wsteczną kompatybilność, ale stara się sobie z tym poradzić przez Optional. Tomek Nurkiewicz w swojej prezentacji „Czego Javowiec nauczy się od Haskella?” na samym początku wspomina, że Haskell nie ma czegoś takiego jak null. Tak samo Kotlin pilnuje nas, abyśmy nie przekazywali pomiędzy metodami braku wartości. Jednak programując w Javie trzeba po prostu do tego przywyknąć i wyrobić sobie nawyk nie pisania return null; jako zakończenia swojej metody.

Zachęcam Cię również do przyjrzenia się zawiłością Javy, które dla Ciebie przygotowałem. Może znajdziesz tam coś o czym nie miałeś pojęcia!

Źródła:

Podziel się tym z innymi!

Może Ci się również spodoba