środa, 28 grudnia 2011

Czy null jest potrzebny?

     W komentarzu do poprzedniego posta Bartosz napisał, że według niego nowo utworzony obiekt z wynullowanymi polami nie zawsze jest błędem i czasami może mieć sens. Nie zgadzam się z tym zupełnie. Powiem więcej: wg mnie, w językach wysokiego poziomu (chodzi mi o takie, gdzie już nie musimy ręcznie alokować i zwalniać pamięci) wartość null nie ma w ogóle racji bytu!
     Wartość NULL występuje w językach takich jak C, czy C++ właśnie po to, żeby reprezentować pusty wskaźnik. Natomiast w językach takich, jak C# czy Java nie zarządza się ręcznie pamięcią, a null w zasadzie nie ma żadnego sensownego uzasadnienia. Ktoś może w tym miejscu zaprotestować i powiedzieć, że null ma np. wiele zastosowań przy implementacji różnego rodzaju kolekcji. Odpowiem na to, że kolekcje da się w językach wysokiego poziomu zaimplementować bardziej sensownie. Zaprezentuję to później na przykładzie języka F#.
     Zanim to zrobię, pokażę cztery linijki kodu, które zszokują zatwardziałych Jawowców i C-Sharpowców :-) W F# poniższy kod:

type MyClass()=
  
member x.Foo () = ()

let mutable x = new MyClass()
<- null

...nie skompiluje się! Kompilator powie, że null nie jest wartością klasy MyClass. Analogicznie jest w przypadku każdej innej klasy zdefiniowanej w tym języku: null nie jest poprawną wartością. W F# słowo kluczowe null występuje tylko i wyłącznie po to, żeby móc korzystać ze standardowych bibliotek .NET-owych.
     Biblioteki F# zamiast wartości null używają unii dyskryminowanych (ang. discriminated union). Najprostszą z nich jest option, który jest zdefiniowany tak:

type 'a option =
  None
  
| Some of 'a

Czyli albo nie mamy wartości (None) albo ją mamy (Some). Option może być użyty w funkcji w następujący sposób:

let makeList opt =
  match opt with
    | None -> []
    | Some x -> [x]

Dla niewtajemniczonych: powyższa funkcja zwraca pustą listę dla braku wartości i jednoelementową listę, gdy poda się wartość.
A co z kolekcjami? Otóż jest równie prosto. Na przykład lista łączona, którą w C# zdefiniowalibyśmy tak:

public class LinkedList<T>
{
    private T head;
    private LinkedList<T> tail;
    [..]
}

w językach funkcyjnych zdefiniowana byłaby przy użyciu unii dyskryminowanej w taki sposób:

type 'a LinkedList =
  Empty
  | NonEmpty of 'a * 'a LinkedList 
 
Według mnie ta druga definicja jest o wiele jaśniejsza. Rekurencyjna definicja listy łączonej jest tu podana wprost - zupełnie jak w definicjach matematycznych. Natomiast w C# (lub w Jawie) trzeba żonglować referencjami i nullami a przez to kod przestaje tu być zrozumiały sam przez się.

14 komentarzy:

  1. Dlatego każdy klepacz powinien mieć za sobą solidne starcie z funkcyjnymi ;)

    OdpowiedzUsuń
  2. no ok:D ale w javie archaiczne cudo jakim jest GarbageColector często sobie nie radzi ze zwalnianiem pamięci i dlatego poleca się przypisywać null do zbędnych obiektów:) c.b.d.u.

    OdpowiedzUsuń
  3. "Według mnie ta druga definicja jest o wiele jaśniejsza" - masz rację, >według ciebie< :)

    "wartość null nie ma w ogóle racji bytu!" - mocne słowa, w żaden sposób nie poparte tym co jest w tym poście. Możesz dywagować ile ci się podoba, ale w każdym języku (tz. w runtime) występuje zarządzanie pamięcią. W C# także rozróżnia się lokowanie obiektów na stosie od lokowania obiektów na stercie - i to jest zależne od programisty.

    Z tego co widzę, to jedyną prawdziwą różnicą między null i none jest nazwa - czyli wiele szumu o nic.

    Andrzej

    OdpowiedzUsuń
  4. Do Andrzeja:
    Napisałeś, że "jedyną prawdziwą różnicą między null i none jest nazwa - czyli wiele szumu o nic."

    A o NullReferenceException / NullPointerException słyszałeś? :-) Trochę pisałem o tym w poprzednim poście.

    OdpowiedzUsuń
  5. Tak, ale co z tego? Jeżeli jest to problem (a rzadko tak jest), to można skorzystać ze wzorca Null Object i tyle.

    W praktyce nie jest to częste, ponieważ brak referencji (nie ważne czy oznaczone przez null czy przez none) i tak trzeba obsłużyć lub zignorować if'em (jak nie to patrz: Null Object Pattern).

    Andrzej

    OdpowiedzUsuń
  6. Nie wydaje mi się, że NPE jest rzadkim problemem. Powiedziałbym raczej, że jest to jeden z najczęstszych wyjątków. Różnica między Null Object Pattern a tym co oferuje F# jest taka, że Null Object Patter NIE jest wymuszane przez kompilator a przez to przez nieuwagę można łatwo zapomnieć o przypisaniu takiego obiektu. Owszem w C# można cudować z jakimiś kontraktami, analizą statyczną, etc. jednak rozwiązanie z F# wydaje się najbardziej eleganckie.

    OdpowiedzUsuń
  7. Które rozwiązanie jest bardziej eleganckie czy jaśniejsze, to kwestia gustu, a o tych się nie dyskutuje. Natomiast rzeczywiście - wymuszenie przez kompilator obsługi braku wartości jest istotną zaletą. Trudno się zgodzić z Andrzejem, że to "wiele szumu o nic".

    OdpowiedzUsuń
  8. Ale to dobrze, że jest wyjątek! Tak ma być. Jeżeli próbujesz wywołać obiekt, który nie istnieje, to wyjątek jest jak najbardziej zasadny. Jeżeli (i tylko wtedy!!!) gdy jest to normalna, przewidywana sytuacja to testujesz if'em lub stosujesz Null Object Pattern. Jednym z częstszych błędów popełnianym przez juniorów, które wychodzą przy codereview jest łapanie wszystkich wyjątków i puszczanie flow programu dalej, bez danych, stanu, itd.

    Najważniejsze to mieć wybór - a to zapewnia C# bez problemu.

    @bozy - nigdzie nie napisałem, że NPE jest rzadkim problemem, tylko że rzadko występowanie NPE jest problemem - a to duża różnica.

    @bozy, @bartekz -
    Skoro zapomnisz obsłużyć NPE to tak samo możesz zapomnieć, że twój algorytm działa na None i nawet runtime ci tego wyjątkiem nie przypomni. A jak często wywołanie bez obiektu jest poprawne? Co z tego, że program się "nie wywali" skoro będzie niepoprawnie działał. Runtime wyjątkiem pomaga znaleźć błąd, który inaczej byłby zignorowany bo None też jest ok?

    Co więcej, elegancja ma małą wartość dla klienta. Jest ważna, ale jest to rzecz trzecio-, albo czwartorzędna.

    OdpowiedzUsuń
  9. @Andrzej -
    "nigdzie nie napisałem, że NPE jest rzadkim problemem, tylko że rzadko występowanie NPE jest problemem - a to duża różnica." -
    słusznie, nieuważnie przeczytałem - mea culpa. Zgadzam się, że na ogół nie jest trudno poprawić taki błąd. Dużo trudniej pamiętać o tym, żeby się przed nim uchronić zawczasu. Przynajmniej mi.

    "Skoro zapomnisz obsłużyć NPE to tak samo możesz zapomnieć, że twój algorytm działa na None i nawet runtime ci tego wyjątkiem nie przypomni." -
    no właśnie nie jestem do końca przekonany. F# automatycznie przypomina Ci o tym, że jakiegoś przypadku (np. Empty) nie obsługujesz. To powoduje, że musisz poważnie się zastanowić nad tym w jaki sposób potraktować tenże przypadek. Owszem, można takie ostrzeżenie olać, tylko że jest to w 95% proszenie się o kłopoty.

    "Co więcej, elegancja ma małą wartość dla klienta. Jest ważna, ale jest to rzecz trzecio-, albo czwartorzędna." -
    nie skomentuję gdyż teza nie jest związana z tematem dyskusji.

    OdpowiedzUsuń
  10. @bozy -
    hmm, napisałeś, że ~"F# automatycznie przypomina o nieobsłużonym przypadku". Może ja czegoś nie wiem? Moja znajomość F# jest tak niska, że nie pochwaliłem się nią w CV, więc może coś mi umknęło, ale...

    Przyjmijmy, że mamy przypadek (pseudo kod):

    klasa ABC {
    metoda XYZ(x1) {
    x1.DoSomething();
    }
    }

    i wywołanie na instancji a1.XYZ(null/empty);

    W przypadku C# dostaniesz exception - normalna sprawa. Jak się zachowa F# i na czym polega ~"automatyczne przypomnienie o nieobsłużeniu Empty"?

    To be clear - w C# w tym przypadku możemy w razie konieczności zastosować Null Object Pattern, i wtedy nie będzie wyjątku tylko najczęściej nic się nie stanie. Ale to musi być przemyślane, bo brak akcji może być gorszy od fatal errora na produkcji (przy fatalu przynajmniej w logu mamy info, gdzie jest błąd i jaki, a w przeciwnym wypadku mamy tylko zgłoszenia od userów "zrobiłem, ale potem się okazało, że nic się nie wykonało").

    Andrzej

    OdpowiedzUsuń
  11. @Andrzej -

    type Callee() =
    member this.Method() =
    printfn "Callee called"

    type Caller() =
    member this.Call(callee : Callee option) =
    match callee with
    | None -> printfn "Nothing"
    // | Some(callee) -> callee.Method()


    let callee = new Callee()
    let caller = new Caller()

    do caller.Call(Some(callee))
    do caller.Call(None)


    Kompilacja powyższego programiku powoduje powstanie następującego ostrzeżenia:
    warning FS0025: Incomplete pattern matches on this expression. For example, the value 'Some (_)' may indicate a case not covered by the pattern(s).
    Wystarczy jednak odkomentować by wszystko gładko poszło. Ten warning właśnie jest przypomnieniem o którym pisałem.

    OdpowiedzUsuń
  12. @bozy - fakt, tego nie znałem. Ale nie rozumiem zalety tego rozwiązania, ani tym bardziej przewagi nad null. W tym i w tym wypadku programista musi podjąć decyzję "obsługuję, ignoruje". Obawiam się, że wprowadzenie takiego "warninga" do C# spowodowało by lawinę "warningów" (w miejscach w ogóle bez znaczenia), przez co programista w ogóle by zaczął ignorować ostrzeżenia kompilatora.

    Nie znam praktyk programistów F#, ale jeżeli jeden na 3 programistów zacznie domyślne pisać obsługę none z "pustą obsługą"* (tak jak w tym przypadku, byle zniknął warning z mojego kodu) to sytuacje wyjątkowe będą ukryte i o wiele trudniej będzie wykryć błąd.

    * - podobny odruch ma 75% programistów C#/C++ pisząc zawsze default w switch'ach.

    OdpowiedzUsuń
  13. @Andrzej
    Programista może również podjąć decyzję, że do metody call w ogóle nie "wpuści" nuli pisząc:

    member this.Call(callee : Callee) =
    callee.Method()

    i wtedy jest najbezpieczniej ;-). W C# musisz o tym sam pamiętać.

    Jeżeli chodzi o wprowadzenie takiego warninga do C# to z tego co wiem niektórzy programiści specjalnie się o to proszą, podłączając do Visual Studio jakieś cudowne narzędzia do analizy statycznej, które te nule potrafią wyśledzić analizując kontrakty. Nie uważam, żeby to była zła praktyka, ponieważ zawsze można się pomylić w określaniu "miejsca bez znaczenia".

    Oczywiście masz sporo racji w tym, że niektórzy zaczną tak pisać, a to z lenistwa a to z niewiedzy lecz w tym przypadku problemów z tym będzie tyle samo co z zastosowaniem wzorca Null Object Pattern (który wydaje się bardzo pożyteczny w niektórych sytuacjach, szczególnie jak się go uzbroi w logowanie)

    OdpowiedzUsuń
  14. Przypomina mi to trochę sytuację z Javy, gdzie wyjątek trzeba było albo obsłużyć wewnątrz metody, albo "markować" metodę, że zgłasza dany wyjątek.

    Tutaj jest podobnie: albo metoda "nie wpuszcza" none i przed jej wykonaniem trzeba sprawdzić, czy parametr nie będzie none, albo wewnątrz metody to robimy.

    Generalnie takie coś jest na pewno na plus, ale nie oznacza to, że null'e /none'y nie są potrzebne ;)

    OdpowiedzUsuń