lukaszstaniszewski.pl

blog programistyczny

lukaszstaniszewski.pl

blog programistyczny

Dlaczego warto unikać null i jak do tego podszedł Rust?

Jeśli miałbym wskazać jedną z najgorszych decyzji w historii programowania, to null byłby na szczycie listy. Tony Hoare, jego twórca, swego czasu przyznał że to błąd warty miliard dolarów, ponieważ null stał się źródłem niezliczonych problemów w systemach na całym świecie. Sam wielokrotnie popełniałem błędy wynikające ze stosowania nulla, na przykład nie zauważając, że jakaś metoda zaczęła w pewnym miejscu zwracać null, co w PHPie jest raczej chlebem powszednim 🙂

Najczęstsze problemy wynikające z null

Poniżej przygotowałem listę najczęściej spotykanych przeze mnie problemów w codziennej pracy.

  1. Nieoczekiwane wartości – tutaj da się to tylko skwitować w jednym zdaniu. Niech pierwszy rzuci kamieniem kto nie ustawił nulla bo nie było typowania (pomijam tutaj języki ścisłego typowania chociaż pewnie każdy dotknął JSa) 🙂
  2. Brak wsparcia w typach – w językach bez ścisłego typowania null może wprowadzać zamieszanie i wymuszać dodatkowe sprawdzanie.
  3. Nieznajomość zasady get, find (ta dobra praktyka nie pojawia się zbyt często a tym bardziej w literaturze ale warto ją znać) – często deweloperzy używają metod w repozytoriach bez świadomości różnic między find a get:
    • find() – zwraca null, jeśli nie znajdzie obiektu/-ów. Przyjmuje się tak dlatego że jak przetłumaczysz find na polski to oznacza ono znajdź a znajdź dopuszcza wynik braku danych.
    • get() – zawsze zwraca obiekt a jeśli nie ma danych rzuca wyjątek. Analogicznie do słowa powyżej, jak przetłumaczysz get to masz tutaj pobierz i jest to żądanie w którym nie oczekujesz innego efektu jak pobranie.
    • Niepoprawna implementacja tych metod prowadzi do sytuacji, w których null przechodzi dalej i generuje błędy w najmniej spodziewanych sytuacjach.
  4. Problemy w Symfony z Doctrine i Symfony/Form – w PHP, a szczególnie w połączeniu Doctrine i Symfony/Form, często dodajemy null na polach w encji, co również prowadzi do błędów. W miejscach, gdzie nigdy nie oczekujemy null (zakładam tutaj że w bazie mamy ustawiony not null), nagle może się on pojawić, co może prowadzić do nieoczekiwanych bugów. Wystarczy przecież że ktoś zmieni definicje tabeli bądź gdzieś przepuści taką wartość a przecież do tego wystarczy brak jednego constraint. W nowszych wersjach Symfony problem ten został zlikwidowany, ale w kodzie legacy nadal może się taka sytuacja zdarzyć.
  5. Brak przeciążania metod w PHP – w PHP ale i w wielu innych językach jak Python, JS itp. brakuje możliwości deklaracji tej samej metody z różnymi parametrami (tzw. method overloading), co często prowadzi do sytuacji, w których deweloperzy zamiast lepiej przemyśleć architekturę, stosują null jako wartość domyślną dla opcjonalnych argumentów, co może skutkować błędami.

Jak Rust rozwiązuje problem null?

Rust podszedł do tematu w sposób zdecydowanie inny – po prostu nie ma null. Zamiast tego wprowadzono typ Option<T>, który jednoznacznie wymusza na programiście rozważenie przypadku braku wartości.

Option<T> – eliminacja problemu null

W Rust mamy dwa warianty typu Option<T>:

enum Option<T> {
    Some(T),
    None,
}

Oznacza to, że zmienna może mieć wartość (Some(value)) lub być pusta (None). Jeśli chcemy użyć wartości, musimy ją jawnie obsłużyć, co wymusza na programiście odpowiednie podejście do obsługi błędów:

fn get_user(id: u32) -> Option<User> {
    let user = database.find_user(id);
    match user {
        Some(u) => Some(u),
        None => None,
    }
}

Dzięki temu wymuszamy, aby każdy, kto używa tej funkcji, jasno określił, co zrobić w przypadku braku użytkownika.

Result<T, E> – obsługa błędów bez wyjątków

Poza Option<T>, Rust posiada również Result<T, E>, który jest jeszcze potężniejszy, ponieważ pozwala jednoznacznie rozróżnić sytuacje poprawne od błędnych:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Dzięki temu funkcje mogą zwracać nie tylko wynik, ale także informację o błędzie:

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("Can not divide by zero"))
    } else {
        Ok(a / b)
    }
}

A potem używamy match, aby obsłużyć zarówno sukces, jak i błąd:

match divide(10.0, 0.0) {
    Ok(result) => println!("Result: {}", result),
    Err(err) => println!("Error: {}", err),
}

Wymuszenie obsługi błędów

W Rust nie możemy „przypadkowo” pominąć sprawdzenia None, co znacząco redukuje błędy:

let user = get_user(10).expect("Użytkownik nie istnieje!");

Albo zapewniamy domyślną wartość:

let user = get_user(10).unwrap_or(default_user());

Podsumowanie

null był historycznym błędem, który kosztował miliardy dolarów i doprowadził do niezliczonych błędów w aplikacjach. Rust w mojej opinii rozwiązują ten problem genialnie dlatego właśnie pojawi się on w tym wpisie. Oczywiście rozwiązanie Rusta można wprowadzać w każdym języku wystarczy tylko ustalona dobra zasada w swoim zespole, obszarze.

Wpis miał na celu pokazać Ci trochę odmienne spojrzenie na nulla i nie oznacza to, że teraz powinieneś rzucić wszystko i całkowicie zrezygnować z null. W wielu przypadkach null może być przydatnym typem do spełnienia zasad DRY, KISS czy SOLID. Problem pojawia się, gdy jest stosowany bezrefleksyjnie – często dodanie null wydaje się szybkim rozwiązaniem problemu, podczas gdy lepsze podejście wymagałoby chwili zastanowienia i innej implementacji. Kluczowe jest więc rozsądne podejście do używania null i świadome projektowanie kodu.

Celowo nie wspomniałem tutaj o bazach danych bo to wgl inny temat na wpis 🙂

Dlaczego warto unikać null i jak do tego podszedł Rust?
Przewiń na górę