Kotlin vs NullPointerException. Czyli jak naprawiono błąd wart miliard dolarów?

blog.geekydevs.com 7 lat temu

Jeśli ktoś słyszał tylko jedną rzecz o Kotlinie, to jest spora szansa, iż rzecz była o sposobie, w jaki radzi on sobie z null’ami – w szczególności z niesławnym NullPointerException (NPE). I wcale się nie dziwię, bo jest to prawdziwy „killer feature”, który eliminuje całą klasę błędów.

Omawiałem to zagadnienie pokrótce w prezentacji na JUGu. Ale wydaje się, iż jest na tyle istotne i dalekosiężne w skutkach, iż zasługuje na szczegółową analizę.

Najpierw omówimy sobie sam mechanizm „obrony” przed NPE – na czym polega, i jak działa? Potem spojrzymy, jak został zaimplementowany na poziomie JVM, i jaki ma wpływ na wydajność aplikacji? A na końcu zobaczymy, jak to wszystko współpracuje z Javą?

Zapraszam do lektury

Billion Dollar Mistake

Jeśli to czytasz, to raczej wiesz czym jest null, i czym grozi próba wywołania jego składowych Znasz też pewnie opinię samego twórcy null-reference – Tony’ego Hoare:

„I call it my billion-dollar mistake.”

Jednak od 1965 roku sporo popularnych języków przyjęło koncepcję null’a w takiej czy innej formie. W każdym z nich widzimy też jakiś sposób radzenia sobie (bądź nie) z problemem przypadkowego odwołania do składowych null’a.

W większości przypadków kończy się to po prostu crashem. W Javie dostaniemy piękny NullPointerException, w C# – NullReferenceException. W Pythonie dowiemy się, iż NoneType nie posiada artybutu, który wołamy. JavaScript obdaruje nas jednym z dwóch wyjątków, w zależności czy mamy do czynienia z null’em czy undefined. Z kolei Objective-C będzie udawać, iż nic się nie stało (a błąd odkryjemy za pół roku, na produkcji). A w C…? Jak to zwykle w C – rezultat jest nie do przewidzenia

Oczywiście nie jesteśmy bezbronni – możemy się bronić, choćby dzięki null-check’ów. Jednak to my, developerzy, musimy o tym pamiętać. I w tym tkwi cały problem…

A jak to jest w Kotlinie?

Kotlin vs NPE

Kotlin, z racji tego iż stawia na pełną współpracę z Javą, również posiada koncepcję null’a. Jednak, w odróżnieniu do powyższych języków, posiada wbudowany mechanizm, który uniemożliwia przypadkowy dostęp do składowych wartości null. Jest to zaimplementowane na poziomie systemu typów, i opiera się na poniższych zasadach:

  1. Wszystkie typy (np. String) są domyślnie nie-nullowalne (ang. non-null) – nie mamy prawa przypisać im wartości null
  2. Możemy jawnie uczynić typ nullowalnym (ang. nullable), poprzez dodanie znaku ? na końcu typu – np. String?
  3. Do elementów składowych typu nullowalnego (np. String?) mamy dostęp dopiero po upewnieniu się, iż tym null’em nie jest – np. poprzez null-check

Efekt jest taki, iż NullPointerException (NPE) nie jest już wyjątkiem czasu wykonania, a jedynie prostym błędem wychwytywanym na etapie kompilacji! Tym samym znika przyczyna większości błędów krytycznych, jakie znamy z Javy

Zobrazujmy sobie powyższe punkty kawałkami kodu.

Punkt 1. mówi, iż mając zmienną typu String, nie możemy do niej przypisać null’a:

var str: String = "xyz" str = null // Compile-time error

Punkt 2. mówi, iż każdy typ na swój odpowiednik ze znakiem ?, który może przyjąć wartość null:

var str: String? = "xyz" str = null // OK

Teraz zajmijmy się punktem 3. Gdyby poniższy przykład napisano np. w Javie, skompilowałby się bez najmniejszego problemu. Natomiast w Kotlinie dostaniemy błąd kompilacji:

fun getLength(str: String?): Int? { return str.length // Compile-time error }

Dlaczego tak jest? Ponieważ istnieje prawdopodobieństwo, iż str będzie null’em. A wtedy próba wywołania adekwatności length zaowocowałaby NPE. Kompilator nie może nam na to pozwolić.

Co więc powinniśmy zrobić, żeby dobrać się do składowej length? Pierwsza opcja to stary dobry null-check:

fun getLength(str: String?): Int? { if (str != null) { return str.length // <-- Smart casting to String } return 0 }

Dzieje się tu interesująca rzecz. Bowiem kompilator wie, iż wewnątrz if’a str na pewno nie będzie null’em, więc automatycznie rzutuje go na odpowiednik nie-nullowalny – czyli String. Dzięki temu nie musimy już robić rzutowania manualnie.

Jednak, jak na Kotlina, to ten kod zrobił się trochę nieczytelny. Czy możemy coś z tym zrobić? Oczywiście!

Razem w systemem typów dostajemy zestaw operatorów, które ułatwią nam pracę z null’ami.

Operator bezpiecznego dostępu

fun getLength(str: String?): Int? { return str?.length }

Powyżej widzimy w działaniu operator ?. – czyli operator bezpiecznego dostępu do składowych. Działa on tak, iż jeżeli wyrażenie po jego lewej stronie (czyli str) nie jest null’em, to wykonuje wyrażenie z prawej strony (czyli length). o ile natomiast po lewej jest null, to w ogóle nie wykonuje strony prawej, tylko od razu zwraca null jako ostateczny wynik.

W powyższym przykładzie funkcja getLength() zwraca typ Int?, ponieważ w przypadku gdy str jest null’em, wynik zwrócony z funkcji też będzie null’em.

Elvis operator

Jeśli natomiast chcemy zwrócić jakąś wartość domyślną zamiast null’a, możemy to zrobić w następujący sposób:

fun getLength(str: String?): Int { return str?.length ?: 0 }

Działanie operatora ?: (tzw. „Elvis operator”) polega na tym, iż jeżeli po swojej lewej stronie dostanie wartość null, to zwraca wartość domyślną podaną z prawej strony.

Operatory możemy łączyć w łańcuchy, dzięki czemu znacznie zyskujemy na zwięzłości i czytelności kodu:

// Java public ZipCode getZipCode(User user) { if (user != null) { if (user.getAddress() != null) { return user.getAddress().getZipCode(); } } return new ZipCode(); } // Kotlin fun getZipCode(user: User?): ZipCode { return user?.address?.zipCode ?: ZipCode() } // Kotlin alternative fun getZipCode(user: User?) = user?.address?.zipCode ?: ZipCode()

Bezpieczne rzutowanie

Skoro już jesteśmy przy temacie „bezpiecznego” dostępu, warto wspomnieć, iż w Kotlinie istnieje również mechanizm „bezpiecznego” rzutowania. O ile zwykłe rzutowanie poprzez operator as może skutkować rzuceniem wyjątku ClassCastException, o tyle rzutowanie „bezpiecznym” operatorem as? po prostu zwróci null:

val user1: User = obj as User // crashes if obj is not User val user2: User? = obj as? User // won't crash; returns null instead

Operator !!

Istnieje jeszcze jeden operator, ale jego użycie jest, delikatnie mówiąc, niezalecane. Na 99% nie będzie Ci potrzebny, więc najlepiej zapomnij o jego istnieniu Mowa o operatorze !!, który po prostu rzuca stary dobry NPE w przypadku wystąpienia null:

val str: String? = null str!!.length // throws NullPointerException

Jak to działa „pod spodem”?

Ok, wiemy już, iż w Kotlinie mamy bliźniacze typy nullowalne i nie-nullowalne, oraz kilka fajnych operatorów do manipulacji nimi. Zastanówmy się teraz, w jaki sposób ten system mógł zostać zaimplementowany?

Pierwsza myśl, jaka przychodzi do głowy, to opakować wszystkie typy w jakiś wrapper typu Option(Some|None) w Scali, albo Nullable<T> w C#. Jednak takie rozwiązanie ma oczywisty minus – negatywy wpływ na wydajność aplikacji (ang. runtime overhead). Ponieważ każdy pojedynczy obiekt musi zostać opakowany w dodatkowy obiekt (wrapper).

Twórcy Kotlina poszli więc inną drogą. I z perspektywy czasu widać, iż była to bardzo dobra decyzja. Rozróżnienie typu nullowalnego od nie-nullowalnego jest zaimplementowane dzięki adnotacji czasu kompilacji – @Nullable i @NonNull. Tak, tych samych, które znamy z Javy

Widać to wyraźnie, gdy zdekompilujemy prosty kod Kotlinowy do Javy:

// Kotlin val str1: String? = "" val str2: String = "" // Decompiled Java import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @Nullable private final String str1 = ""; @NotNull private final String str2 = "";

Pierwszą, oczywistą zaletą tego rozwiązania jest zerowy wpływ na wydajność aplikacji, gdyż walidacja odbywa się w czasie kompilacji.

Druga, mniej oczywista, ale równie ważna, dotyczy kooperacji z Javą…

Jak to działa z Javą?

Jako iż implementacja typów nullable/non-null w Kotlinie oparta jest na standardowych adnotacjach, w pakiecie (niejako w bonusie) otrzymujemy również wnioskowanie typów Javowych.

O co dokładnie chodzi? O to, iż jeżeli mamy w Javie metodę, która zwraca String i jest oznaczona jako @NonNull, to Kotlin widzi jej typ jako String, a nie String?. Odpowiednio, jeżeli metoda jest oznaczona jako @Nullable, to Kotlin widzi ją jako String?, czym wymusza na nas „bezpieczne” podejście.

Działa to zarówno dla wartości zwracanych, jak i parametrów metod:

// Java class TestJava { @Nullable static String doSomething(@NonNull String param) { if (param.length() > 3) { return param + param; } return null; } } // Kotlin val str = TestJava.doSomething("kotlin") // String? println(str.length) // Compile-time error: requires safe call operator ?. val str2 = TestJava.doSomething(null) // Compile-time error: cannot pass null in place of non-null type String

Kompilator Kotlina rozpoznaje adnotacje z kilku popularnych bibliotek (lista tutaj).

A co w przypadku, gdy wołamy metodę z Javy, która nie jest oznaczona żadną adnotacją? Tutaj sprawa robi się ciekawa…

Platform types

W pierwszych wersjach Kotlina założenie było proste: skoro coś może być null’em, to dajemy temu typ nullowalny (np. Int?). Wydaje się oczywiste. Jednak w praktyce okazało się, po pierwsze, niewygodne – bo wszędzie musieliśmy robić null-check’i, choćby gdy byliśmy 100% pewni, iż null’a nigdy tam nie będzie. A dodatkowo sprawa komplikowała się, gdy mieliśmy do czynienia z kolekcjami generycznymi (o szczegółach można posłuchać tutaj).

Dlatego, koniec końców, wprowadzono tzw. „platform types”, które są czymś pośrednim między nullable i non-null. Mogą przyjąć wartość null, ale nie musimy robić null-checków, więc istnieje niebezpieczeństwo NPE. Czyli działa to dokładnie jak w Javie. No niestety, zło konieczne…

Podsumowanie

Podsumowując, Kotlin oferuje pełny pakiet ochrony przed NPE, na który składają się: typy standardowe które nie mogą przyjąć wartości null, oraz bliźniacze typy nullowalne, wraz z zestawem operatorów do bezpiecznego wołania ich składowych.

Dopóki obracamy się wyłącznie w kodzie Kotlinowym, jesteśmy w 100% chronieni. Podobnie gdy współpracujemy z kodem Javowym oznaczonym adnotacjami @Nullable i @NonNull. Wyjątkiem jest kooperacja z kodem Javy bez adnotacji – wtedy mamy bezpieczeństwo na poziomie Javy – czyli żadne

To wszystko na dziś. Do następnego wpisu!

Idź do oryginalnego materiału