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

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:

  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ę, że tym null’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!

 

Nie przegap kolejnych wpisów - subskrybuj!

4 komentarzy

    1. 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/

Dodaj komentarz