Jeśli ktoś słyszał tylko jedną rzecz o Kotlinie, to jest spora szansa, że 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ę, że jest na tyle istotne i dalekosiężne w skutkach, że 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ę, że 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ć, że 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 za pomocą 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 że 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:
- Wszystkie typy (np.
String
) są domyślnie nie-nullowalne (ang. non-null) – nie mamy prawa przypisać im wartościnull
- Możemy jawnie uczynić typ nullowalnym (ang. nullable), poprzez dodanie znaku
?
na końcu typu – np.String?
- Do elementów składowych typu nullowalnego (np.
String?
) mamy dostęp dopiero po upewnieniu się, że tymnull
’em nie jest – np. poprzez null-check
Efekt jest taki, że 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, że 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, że 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, że str
będzie null
’em. A wtedy próba wywołania właściwoś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 ciekawa rzecz. Bowiem kompilator wie, że 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 ręcznie.
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, że jeśli wyrażenie po jego lewej stronie (czyli str
) nie jest null
’em, to wykonuje wyrażenie z prawej strony (czyli length
). Jeżeli 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, że jeśli 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ć, że 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ż, że 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ć, że była to bardzo dobra decyzja. Rozróżnienie typu nullowalnego od nie-nullowalnego jest zaimplementowane za pomocą 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, lecz równie ważna, dotyczy kooperacji z Javą…
Jak to działa z Javą?
Jako że 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, że jeśli 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śli 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, nawet gdy byliśmy 100% pewni, że 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 współpraca z kodem Javy bez adnotacji – wtedy mamy bezpieczeństwo na poziomie Javy – czyli żadne 😉
To wszystko na dziś. Do następnego wpisu!
Bardzo fajnie wytłumaczone, dobra robota, fajnie się czyta, dzięki 🙂
Dzięki Michał! 🙂 Cieszę się, że piszę w miarę przystępnie. W końcu, mieć jakąś wiedzę, a umieć ją przekazać, to dwie zupełnie różne rzeczy 😉 A jeśli wkręcasz się w temat Kotlina, to zapraszam do czytania – tych postów trochę już tutaj jest 😀 http://blog.geekydevs.com/tag/kotlin/
Ciekawy wpis, dzięki!
Dzięki 😉 Polecam się na przyszłość 😀