Jeśli wartość null jest zła, dlaczego nowoczesne języki ją implementują? [Zamknięte]

82

Jestem pewien, że projektanci języków takich jak Java czy C # znali problemy związane z istnieniem zerowych referencji (zobacz Czy referencje zerowe są naprawdę złe? ). Także implementacja typu opcji nie jest tak naprawdę dużo bardziej złożona niż odwołania zerowe.

Dlaczego mimo to postanowili to uwzględnić? Jestem pewien, że brak pustych referencji zachęciłby (a nawet wymusiłby) kod lepszej jakości (zwłaszcza lepszy projekt biblioteki) zarówno od twórców języków, jak i użytkowników.

Czy to po prostu z powodu konserwatyzmu - „inne języki to mają, my też musimy to mieć…”?

mrpyo
źródło
99
null jest świetny. Uwielbiam to i używam go na co dzień.
Pieter B
17
@PieterB Ale czy używasz go do większości referencji, czy też chcesz, aby większość referencji nie była pusta? Argumentem nie jest to, że nie powinny istnieć zerowalne dane, a jedynie to, że powinny być jawne i wyrażać zgodę.
11
@PieterB Ale kiedy większość nie powinna być zerowalna, czy nie ma sensu uznawać zerowej zdolności za wyjątek, a nie za domyślny? Zwróć uwagę, że chociaż typowym projektem typów opcji jest wymuszenie jawnego sprawdzania nieobecności i rozpakowywania, można również zastosować dobrze znaną semantykę Java / C # / ... dla opcjonalnych odwołań zerowalnych (użyj, jakby nie było zerowania, wysadzić jeśli null). Pozwoliłoby to przynajmniej uniknąć niektórych błędów i uczynić analizę statyczną, która narzeka na brak kontroli zerowej, o wiele bardziej praktyczną.
20
WTF jest z wami? Ze wszystkich rzeczy, które mogą i robią coś złego w oprogramowaniu, próba wyzerowania wartości zerowej nie stanowi żadnego problemu. ZAWSZE generuje błąd AV / segfault i tak zostaje naprawiony. Czy brakuje tak dużo błędów, że musisz się tym martwić? Jeśli tak, mam mnóstwo zapasów i żadne z nich nie wywołuje problemów z zerowymi referencjami / wskaźnikami.
Martin James
13
@MartinJames „ZAWSZE generuje błąd AV / segfault i zostaje naprawiony” - nie, nie, nie działa.
detly

Odpowiedzi:

97

Oświadczenie: Ponieważ nie znam żadnych projektantów języków, każda udzielona przeze mnie odpowiedź będzie spekulacyjna.

Od samego Tony'ego Hoare'a :

Nazywam to moim błędem za miliard dolarów. Był to wynalazek referencji zerowej w 1965 roku. W tym czasie projektowałem pierwszy kompleksowy system typów dla referencji w języku obiektowym (ALGOL W). Moim celem było upewnienie się, że każde użycie referencji powinno być całkowicie bezpieczne, a sprawdzanie wykonywane automatycznie przez kompilator. Ale nie mogłem oprzeć się pokusie wprowadzenia zerowego odniesienia, po prostu dlatego, że było to tak łatwe do wdrożenia. Doprowadziło to do niezliczonych błędów, podatności i awarii systemu, które prawdopodobnie spowodowały miliard dolarów bólu i szkód w ciągu ostatnich czterdziestu lat.

Podkreśl moje.

Oczywiście wtedy nie wydawało mu się to złym pomysłem. Prawdopodobnie zostało to częściowo utrwalone z tego samego powodu - jeśli wydawało się to dobrym pomysłem dla zdobywcy nagrody Turinga wynalazcy Quicksort, nic dziwnego, że wiele osób wciąż nie rozumie, dlaczego jest zły. Prawdopodobnie jest to po części dlatego, że wygodne jest, aby nowe języki były podobne do starszych języków, zarówno ze względów marketingowych, jak i uczenia się. Przykładem:

„Szukaliśmy programistów C ++. Udało nam się przeciągnąć wielu z nich w połowie drogi do Lisp.” -Guy Steele, współautor specyfikacji Java

(Źródło: http://www.paulgraham.com/icad.html )

I oczywiście C ++ ma wartość null, ponieważ C ma wartość null, i nie ma potrzeby wchodzenia w historyczny wpływ C. C # zastąpił J ++, który był implementacją Javy przez Microsoft, a także zastąpił C ++ jako język wyboru dla rozwoju Windows, więc mógł go pobrać z dowolnego z nich.

EDYCJA Oto kolejny cytat z Hoare, który warto rozważyć:

Języki programowania są o wiele bardziej skomplikowane niż kiedyś: orientacja obiektowa, dziedziczenie i inne cechy wciąż nie są tak naprawdę przemyślane z punktu widzenia spójnej i naukowo uzasadnionej dyscypliny lub teorii poprawności . Mój pierwotny postulat, który realizowałem przez całe życie jako naukowiec, jest taki, że używa się kryteriów poprawności jako sposobu na zbliżenie się do przyzwoitego projektu języka programowania - takiego, który nie zastawia pułapek dla użytkowników i które różne komponenty programu wyraźnie odpowiadają różnym komponentom jego specyfikacji, więc możesz o tym myśleć logicznie. [...] Narzędzia, w tym kompilator, muszą być oparte na pewnej teorii, co oznacza napisanie poprawnego programu. - wywiad z historią oralną Philipa L. Frany, 17 lipca 2002 r., Cambridge, Anglia; Charles Babbage Institute, University of Minnesota. [ Http://www.cbi.umn.edu/oh/display.phtml?id=343]

Ponownie podkreśl moje. Sun / Oracle i Microsoft to firmy, a podstawą każdej firmy są pieniądze. Korzyści, jakie im nullprzyniosły, mogły przeważyć nad wadami lub mogli po prostu mieć zbyt krótki termin, aby w pełni rozważyć problem. Jako przykład pomyłki w innym języku, która prawdopodobnie wystąpiła z powodu terminów:

Szkoda, że ​​Klonowalny jest zepsuty, ale tak się dzieje. Oryginalne interfejsy API Java zostały wykonane bardzo szybko w krótkim terminie, aby sprostać zamykającemu się rynkowi. Oryginalny zespół Java wykonał niesamowitą robotę, ale nie wszystkie interfejsy API są idealne. Klonowanie jest słabym punktem i myślę, że ludzie powinni zdawać sobie sprawę z jego ograniczeń. -Josh Bloch

(Źródło: http://www.artima.com/intv/bloch13.html )

Doval
źródło
32
Drogi downvoter: jak mogę poprawić swoją odpowiedź?
Doval
6
Właściwie nie odpowiedziałeś na pytanie; podałeś tylko kilka cytatów na temat niektórych opinii po fakcie i dodatkowe machanie ręką na temat „kosztów”. (Jeśli zero jest błędem
wartym
29
@DougM Czego oczekujesz ode mnie, trafiłem każdego projektanta języka z ostatnich 50 lat i zapytałem go, dlaczego zaimplementował nullw swoim języku? Każda odpowiedź na to pytanie będzie spekulacyjna, chyba że pochodzi od projektanta języka. Nie znam żadnej tak częstej strony oprócz Erica Lipperta. Ostatnia część to czerwony śledź z wielu powodów. Ilość kodu strony trzeciej napisanego na interfejsach API MS i Java w oczywisty sposób przewyższa ilość kodu w samym API. Więc jeśli Twoi klienci tego chcą null, dajesz im null. Podejrzewasz również, że zaakceptowali nullto kosztem pieniędzy.
Doval
3
Jeśli jedyną odpowiedzią, jaką możesz udzielić, jest spekulacyjny, wyraźnie zaznacz to w akapicie otwierającym. (Pytałeś, w jaki sposób możesz poprawić swoją odpowiedź, a ja odpowiedziałem. Każdy nawias to tylko komentarz, który możesz zignorować; przecież po to są nawiasy w języku angielskim.)
DougM
7
Ta odpowiedź jest rozsądna; Dodałem jeszcze kilka uwag do siebie. Zauważam, że ICloneablepodobnie jest uszkodzony w .NET; niestety jest to jedno z miejsc, w których nie odkryto braków w Javie.
Eric Lippert
121

Jestem pewien, że projektanci języków takich jak Java czy C # znali problemy związane z istnieniem zerowych referencji

Oczywiście.

Także implementacja typu opcji nie jest tak naprawdę dużo bardziej złożona niż odwołania zerowe.

Pozwolę sobie być innego zdania! Rozważania projektowe, które weszły w typy wartości zerowalnych w C # 2, były złożone, kontrowersyjne i trudne. Zabrali zespoły projektantów obu języków i środowiska wykonawczego na wiele miesięcy debaty, wdrożenia prototypów i tak dalej, aw rzeczywistości semantyka nokautowalnego boksu została zmieniona bardzo blisko wysyłki C # 2.0, co było bardzo kontrowersyjne.

Dlaczego mimo to postanowili to uwzględnić?

Cały projekt jest procesem wybierania spośród wielu subtelnie i rażąco niezgodnych celów; Mogę jedynie przedstawić krótki szkic tylko kilku czynników, które należy wziąć pod uwagę:

  • Ortogonalność cech językowych jest ogólnie uważana za dobrą rzecz. C # ma typy wartości dopuszczających wartości zerowe, typy wartości nie dopuszczających wartości zerowych i typy wartości dopuszczających wartości zerowe. Niewymienne typy referencyjne nie istnieją, co powoduje, że system typów nie jest ortogonalny.

  • Znajomość istniejących użytkowników C, C ++ i Java jest ważna.

  • Ważna jest łatwa interoperacyjność z COM.

  • Ważna jest łatwa współpraca ze wszystkimi innymi językami .NET.

  • Ważna jest łatwa interoperacyjność z bazami danych.

  • Ważna jest spójność semantyki; jeśli mamy odniesienie TheKingOfFrance równe null, czy to zawsze oznacza „nie ma teraz króla Francji”, czy może to również oznaczać „zdecydowanie króla Francji; po prostu nie wiem, kto jest teraz”? czy może to oznaczać „samo pojęcie posiadania króla we Francji jest nonsensowne, więc nawet nie zadawaj pytania!”? Null może oznaczać wszystkie te rzeczy i więcej w języku C #, a wszystkie te pojęcia są przydatne.

  • Koszt wydajności jest ważny.

  • Ważna jest możliwość poddania się analizie statycznej.

  • Ważna jest spójność systemu typów; czy zawsze możemy wiedzieć, że odwołanie, które nie ma wartości zerowej, nigdy nie jest w żadnym wypadku uważane za nieprawidłowe? A co z konstruktorem obiektu o niepisającym polu typu referencyjnego? A co z finalizatorem takiego obiektu, w którym obiekt jest finalizowany, ponieważ kod, który miał wypełnić referencję, zwrócił wyjątek ? System typów, który kłamie na temat swoich gwarancji, jest niebezpieczny.

  • A co z konsekwencją semantyki? Null wartości propagować kiedy używany, ale zerowe referencje rzucać wyjątki podczas eksploatacji. To niespójne; czy ta niespójność jest uzasadniona jakąś korzyścią?

  • Czy możemy wdrożyć tę funkcję, nie psując innych funkcji? Jakie inne możliwe przyszłe funkcje wyklucza ta funkcja?

  • Idziesz na wojnę z armią, którą masz, a nie tą, którą chcesz. Pamiętaj, że C # 1.0 nie miał generycznych, więc mówienie o tym Maybe<T>jako alternatywie jest kompletnym non-starterem. Czy .NET powinien tracić ważność przez dwa lata, podczas gdy zespół wykonawczy dodawał ogólne, wyłącznie w celu wyeliminowania pustych referencji?

  • Co z spójnością systemu typów? Możesz powiedzieć Nullable<T>dla każdego rodzaju wartości - nie, czekaj, to kłamstwo. Nie można powiedzieć Nullable<Nullable<T>>. Powinieneś być w stanie? Jeśli tak, to jaka jest jego pożądana semantyka? Czy warto, aby cały system typów miał w tym przypadku specjalny przypadek tylko dla tej funkcji?

I tak dalej. Te decyzje są złożone.

Eric Lippert
źródło
12
+1 za wszystko, ale przede wszystkim za generyczne. Łatwo zapomnieć, że w historii Java i C # istniały okresy, w których nie istniały leki generyczne.
Doval
2
Może głupie pytanie (jestem tylko studentem informatyki) - ale nie można zaimplementować typu opcji na poziomie składni (z CLR nic o tym nie wiedząc) jako regularnego dopuszczalnego źródła, które wymaga sprawdzenia „ma wartość” przed użyciem w kod? Uważam, że typy opcji nie wymagają żadnych kontroli w czasie wykonywania.
mrpyo
2
@mrpyo: Jasne, to możliwy wybór implementacji. Żadne inne wybory projektowe nie znikają, a ten wybór implementacji ma wiele zalet i wad.
Eric Lippert
1
@mrpyo Myślę, że wymuszenie sprawdzenia „ma wartość” nie jest dobrym pomysłem. Teoretycznie jest to bardzo dobry pomysł, ale w praktyce IMO przyniosłoby wszelkiego rodzaju puste kontrole, aby zaspokoić kompilator - podobnie jak sprawdzone wyjątki w Javie i ludzie oszukiwający go catchestym, że nic nie robią. Myślę, że lepiej jest pozwolić, aby system wysadził w powietrze, zamiast kontynuować pracę w potencjalnie nieprawidłowym stanie.
Nic nie można
2
@voo: Tablice niepoprawnego typu odniesienia są trudne z wielu powodów. Istnieje wiele możliwych rozwiązań, a wszystkie z nich nakładają koszty na różne operacje. Sugestia Supercata polega na sprawdzeniu, czy element można legalnie odczytać przed przypisaniem, co powoduje koszty. Twoim zadaniem jest upewnienie się, że inicjalizator działa na każdym elemencie, zanim tablica będzie widoczna, co nakłada inny zestaw kosztów. Oto rubla: bez względu na to, którą z tych technik wybierzesz, ktoś będzie narzekał, że nie jest to skuteczne w przypadku ich scenariusza ze zwierzętami domowymi. To poważne argumenty przeciwko tej funkcji.
Eric Lippert
28

Null służy bardzo słusznemu celowi reprezentowania braku wartości.

Powiem, że jestem najbardziej głośną osobą, jaką znam na temat nadużywania wartości zerowej oraz wszystkich bólów głowy i cierpienia, jakie mogą powodować, zwłaszcza gdy są stosowane swobodnie.

Moje osobiste stanowisko jest takie, że ludzie mogą stosować wartości zerowe tylko wtedy, gdy mogą uzasadnić, że jest to konieczne i właściwe.

Przykład uzasadniający wartości null:

Data śmierci to zazwyczaj pole zerowalne. Istnieją trzy możliwe sytuacje z datą śmierci. Albo dana osoba zmarła, a data jest znana, dana osoba zmarła, a data jest nieznana, lub dana osoba nie jest martwa, a zatem data śmierci nie istnieje.

Data śmierci jest również polem DateTime i nie ma wartości „nieznana” ani „pusta”. Ma domyślną datę, która pojawia się, gdy tworzysz nową datę, która różni się w zależności od używanego języka, ale technicznie istnieje szansa, że ​​dana osoba faktycznie umarła w tym czasie i oznaczałaby jako „pustą wartość”, gdybyś użyj domyślnej daty.

Dane musiałyby właściwie przedstawiać sytuację.

Osoba nie żyje Data śmierci jest znana (3/9/1984)

Prosty, „3/9/1984”

Osoba zmarła data śmierci nieznana

Co jest najlepsze? Null , „0/0/0000” lub „01/01/1869” (czy jakakolwiek wartość domyślna?)

Osoba nie jest martwa data śmierci nie dotyczy

Co jest najlepsze? Null , „0/0/0000” lub „01/01/1869” (czy jakakolwiek wartość domyślna?)

Pomyślmy więc o każdej wartości nad ...

  • Null , ma implikacje i obawy, przed którymi musisz się uważać, przypadkowo próbując manipulować nim bez potwierdzenia, że ​​najpierw nie jest zerowy, na przykład rzuci wyjątek, ale najlepiej reprezentuje faktyczną sytuację ... Jeśli dana osoba nie jest martwa data śmierci nie istnieje ... to nic ... jest zerowa ...
  • 0/0/0000 , W niektórych językach może to być w porządku, a nawet może być odpowiednią reprezentacją braku daty. Niestety niektóre języki i sprawdzanie poprawności odrzuci to jako niepoprawną datę i godzinę, co sprawia, że ​​w wielu przypadkach jest to niemożliwe.
  • 1/1/1869 (lub jakakolwiek jest twoja domyślna wartość daty / godziny) , problemem jest to, że trudno jest sobie z tym poradzić. Możesz użyć tego jako swojej wartości, z wyjątkiem tego, co się stanie, jeśli chcę odfiltrować wszystkie moje rekordy, dla których nie mam daty śmierci? Mogę z łatwością odfiltrować osoby, które faktycznie zmarły w tym dniu, co może powodować problemy z integralnością danych.

Faktem jest czasem trzeba Czy trzeba reprezentować nic i pewien typ zmiennej czasami działa dobrze, ale często typy zmiennych muszą być zdolne do reprezentowania nic.

Jeśli nie mam jabłek, mam 0 jabłek, ale co, jeśli nie wiem, ile mam jabłek?

Z całą pewnością zero jest nadużywane i potencjalnie niebezpieczne, ale czasami jest konieczne. W wielu przypadkach jest to ustawienie domyślne, ponieważ dopóki nie podam wartości, brak wartości i coś musi ją reprezentować. (Zero)

RualStorge
źródło
37
Null serves a very valid purpose of representing a lack of value.An Optionlub Maybetype spełnia ten bardzo ważny cel bez omijania systemu typów.
Doval
34
Nikt nie twierdzi, że nie powinna istnieć wartość braku wartości, twierdzą, że wartości, które mogą brakować, powinny być wyraźnie oznaczone jako takie, a nie każda wartość potencjalnie brakująca.
2
Myślę, że RualStorge mówił o SQL, ponieważ istnieją obozy, które stwierdzają, że każda kolumna powinna być oznaczona jako NOT NULL. Moje pytanie nie było jednak związane z RDBMS ...
mrpyo
5
+1 za rozróżnienie między „brakiem wartości” a „nieznaną wartością”
David
2
Czy nie byłoby sensowniej rozróżniać stanu osoby? Tj. PersonTyp ma statepole typu State, które jest dyskryminowanym połączeniem Alivei Dead(dateOfDeath : Date).
jon-hanson
10

Nie posunąłbym się tak daleko, że „inne języki to mają, my też musimy to mieć…” jakby to było coś w rodzaju nadążania za Jonesami. Kluczową cechą każdego nowego języka jest możliwość współpracy z istniejącymi bibliotekami w innych językach (czytaj: C). Ponieważ C ma wskaźniki zerowe, warstwa interoperacyjności koniecznie potrzebuje pojęcia null (lub innego odpowiednika „nie istnieje”, który pojawia się, gdy go używasz).

Projektant języka mógł wybrać Typy Opcji i zmusić cię do obsługi ścieżki zerowej wszędzie tam , gdzie rzeczy mogą być zerowe. I to prawie na pewno doprowadziłoby do zmniejszenia liczby błędów.

Ale (szczególnie w przypadku Java i C # ze względu na czas ich wprowadzenia i odbiorców docelowych) użycie typów opcji dla tej warstwy interoperacyjności prawdopodobnie zaszkodziłoby, gdyby nie storpedowało ich przyjęcia. Albo typ opcji jest przekazywany aż do góry, irytując programistów C ++ od połowy do końca lat 90-tych - lub warstwa interoperacyjności rzucałaby wyjątki przy napotkaniu zer, denerwując programistów C ++ od połowy do końca lat 90-tych. ..

Telastyn
źródło
3
Pierwszy akapit nie ma dla mnie sensu. Java nie ma współdziałania C w kształcie, który sugerujesz (jest JNI, ale już przeskakuje przez tuzin obręczy dla wszystkiego, co dotyczy odniesień; do tego rzadko jest stosowany w praktyce), to samo dla innych „nowoczesnych” języków.
@ delnan - przepraszam, bardziej znam C #, który ma tego rodzaju interop. Raczej założyłem, że wiele fundamentalnych bibliotek Java również używa JNI na dole.
Telastyn
6
Dobry argument przemawia za dopuszczeniem wartości null, ale nadal można zezwolić na wartość null bez zachęcania . Scala jest tego dobrym przykładem. Może bezproblemowo współpracować z aplikacjami Java, które używają wartości null, ale zachęcamy do zawinięcia go Optiondo użytku w Scali, co jest tak proste, jak val x = Option(possiblyNullReference). W praktyce nie trzeba długo czekać, aby ludzie zobaczyli zalety Option.
Karl Bielefeldt
1
Typy opcji idą w parze z (weryfikowanym statystycznie) dopasowaniem wzorca, którego C # niestety nie ma. F # robi to i jest cudowne.
Steven Evers
1
@SteveEvers Można go sfałszować za pomocą abstrakcyjnej klasy bazowej z prywatnym konstruktorem, zamkniętych klas wewnętrznych i Matchmetody, która przyjmuje delegatów jako argumenty. Następnie przekazujesz wyrażenia lambda do Match(punkty bonusowe za używanie nazwanych argumentów) i Matchwywołujesz właściwe.
Doval
7

Po pierwsze, myślę, że wszyscy możemy się zgodzić, że koncepcja nieważności jest konieczna. Istnieją sytuacje, w których musimy przedstawić brak informacji.

Zezwalanie na nullodwołania (i wskaźniki) to tylko jedna implementacja tej koncepcji i być może najpopularniejsza, chociaż wiadomo, że ma problemy: C, Java, Python, Ruby, PHP, JavaScript, ... wszystkie używają podobnych null.

Dlaczego ? Jaka jest alternatywa?

W językach funkcyjnych, takich jak Haskell masz Optionlub Maybetypu; są one jednak oparte na:

  • typy parametryczne
  • algebraiczne typy danych

Czy oryginalne C, Java, Python, Ruby lub PHP obsługiwały którąkolwiek z tych funkcji? Nie. Wady języka generycznego Javy są najnowsze w historii tego języka i wątpię, żeby inni w ogóle je wdrożyli.

Masz to. nulljest łatwe, parametryczne algebraiczne typy danych są trudniejsze. Ludzie wybrali najprostszą alternatywę.

Matthieu M.
źródło
+1 dla „null jest łatwe, parametryczne algebraiczne typy danych są trudniejsze”. Ale myślę, że to nie był tak duży problem z typowaniem parametrycznym i trudnością z ADT; po prostu nie są postrzegane jako konieczne. Z drugiej strony, jeśli Java byłaby dostarczana bez systemu obiektowego, zostałaby odrzucona; OOP był funkcją „showstopping”, ponieważ jeśli go nie masz, nikt nie jest zainteresowany.
Doval
@Doval: cóż, OOP mogło być konieczne dla Javy, ale nie dla C :) Ale to prawda, że ​​Java miała być prosta. Niestety ludzie wydają się zakładać, że prosty język prowadzi do prostych programów, co jest dość dziwne (Brainfuck to bardzo prosty język ...), ale z pewnością zgadzamy się, że skomplikowane języki (C ++ ...) również nie są panaceum, chociaż mogą być niezwykle przydatne.
Matthieu M.
1
@MatthieuM .: Prawdziwe systemy są złożone. Dobrze zaprojektowany język, którego złożoność odpowiada modelowanemu systemowi w świecie rzeczywistym, umożliwia modelowanie złożonego systemu za pomocą prostego kodu. Próby nadmiernego uproszczenia języka po prostu zwiększają złożoność programisty, który go używa.
supercat
@ superupat: Nie mogłem więcej zgodzić się. Albo, jak sparafrazowano Einsteina: „Uczyń wszystko tak prostym, jak to możliwe, ale nie prostszym”.
Matthieu M.,
@MatthieuM .: Einstein był mądry na wiele sposobów. Języki, które próbują założyć, że „wszystko jest przedmiotem, do którego można zapisać odniesienie Object”, nie rozpoznają, że praktyczne aplikacje potrzebują niepodzielnych obiektów zmiennych i współdzielonych obiektów niezmiennych (które powinny zachowywać się jak wartości), a także współdzielenia i nieudostępnienia podmioty. Użycie jednego Objectrodzaju do wszystkiego nie eliminuje potrzeby takich rozróżnień; utrudnia to tylko ich prawidłowe użycie.
supercat
5

Samo zero / zero / zero nie jest złe.

Jeśli patrzysz na jego myląco nazwane słynne przemówienie „Błąd miliarda dolarów”, Tony Hoare mówi o tym, jak zezwolenie dowolnej zmiennej na utrzymanie wartości zerowej było wielkim błędem. Alternatywą - używając opcji - czy nie w rzeczywistości pozbyć referencji null. Zamiast tego pozwala określić, które zmienne mogą mieć wartość null, a które nie.

W rzeczywistości, w nowoczesnych językach, które implementują odpowiednią obsługę wyjątków, błędy zerowania zerowego nie różnią się niczym od innych wyjątków - znajdziesz go, naprawisz. Niektóre alternatywy dla odwołań zerowych (na przykład wzorzec obiektu zerowego) ukrywają błędy, powodując dyskretne awarie aż do dużo później. Moim zdaniem znacznie lepiej szybko zawieść .

Pytanie brzmi zatem, dlaczego języki nie wdrażają Opcji? W rzeczywistości prawdopodobnie najpopularniejszy język wszechczasów C ++ ma zdolność definiowania zmiennych obiektowych, których nie można przypisać NULL. Jest to rozwiązanie „problemu zerowego”, o którym wspomniał Tony Hoare w swoim przemówieniu. Dlaczego następny najpopularniejszy język maszynowy, Java, nie ma go? Ktoś może zapytać, dlaczego ma tak wiele wad w ogóle, szczególnie w swoim systemie typów. Nie sądzę, żebyś naprawdę mógł powiedzieć, że języki systematycznie popełniają ten błąd. Niektórzy tak robią, inni nie.

BT
źródło
1
Jedną z największych zalet Javy z punktu widzenia implementacji, ale słabości z perspektywy językowej jest to, że istnieje tylko jeden nieprymitywny typ: Promiscuous Object Reference. To ogromnie upraszcza środowisko wykonawcze, umożliwiając bardzo lekkie implementacje JVM. Ten projekt oznacza jednak, że każdy typ musi mieć wartość domyślną, a dla Promiscuous Object Reference jedyną możliwą wartością domyślną jest null.
supercat
W każdym razie jeden główny typ prymitywny. Dlaczego jest to słabość z perspektywy językowej? Nie rozumiem, dlaczego ten fakt wymaga, aby każdy typ miał wartość domyślną (lub odwrotnie, dlaczego wiele typów root pozwala, aby typy nie miały wartości domyślnej), ani dlaczego jest to słabość.
BT
Jaki inny rodzaj prymitywnych elementów może zawierać element pola lub tablicy? Słabość polega na tym, że niektóre odniesienia są używane do enkapsulacji tożsamości, a niektóre do enkapsulacji wartości zawartych w zidentyfikowanych w ten sposób obiektach. Dla zmiennych typu referencyjnego używanych do enkapsulacji tożsamości nulljest jedynym sensownym domyślnym. Odwołania użyte do enkapsulacji wartości mogą jednak mieć rozsądne zachowanie domyślne w przypadkach, w których typ miałby lub mógłby skonstruować rozsądną domyślną instancję. Wiele aspektów zachowania referencji zależy od tego, czy i w jaki sposób zawierają wartość, ale ...
supercat
... system typów Java nie może tego wyrazić. Jeśli foozawiera jedyne odwołanie do elementu int[]zawierającego, {1,2,3}a kod chce fooprzechowywać odniesienie do elementu int[]zawierającego {2,2,3}, najszybszym sposobem na osiągnięcie tego byłoby zwiększenie foo[0]. Jeśli kod chce, aby metoda wiedziała, że ​​się footrzyma {1,2,3}, druga metoda nie zmodyfikuje tablicy ani nie utrwali referencji poza punktem, w którym foochciałaby ją zmodyfikować, najszybszym sposobem na osiągnięcie tego byłoby przekazanie referencji do tablicy. Jeśli Java ma typ „efemerycznego odwołania tylko do odczytu”, to ...
supercat
... tablica może być bezpiecznie przekazana jako efemeryczne odniesienie, a metoda, która chciała zachować swoją wartość, wiedziałaby, że musi ją skopiować. W przypadku braku takiego typu, jedynymi sposobami bezpiecznego ujawnienia zawartości tablicy jest albo jej skopiowanie, albo kapsułkowanie w obiekcie stworzonym właśnie do tego celu.
supercat
4

Ponieważ języki programowania są ogólnie zaprojektowane tak, aby były praktycznie użyteczne, a nie technicznie poprawne. Faktem jest, że nullstany są częstym zjawiskiem z powodu złych lub brakujących danych lub stanu, który nie został jeszcze ustalony. Technicznie lepsze rozwiązania są bardziej nieporęczne niż po prostu zezwalanie na stany zerowe i wysysanie z faktu, że programiści popełniają błędy.

Na przykład, jeśli chcę napisać prosty skrypt, który działa z plikiem, mogę napisać pseudokod taki jak:

file = openfile("joebloggs.txt")

for line in file
{
  print(line)
}

i po prostu zawiedzie, jeśli plik joebloggs.txt nie istnieje. Chodzi o to, że w przypadku prostych skryptów jest to prawdopodobnie w porządku, a dla wielu sytuacji w bardziej złożonym kodzie wiem, że on istnieje i awaria się nie zdarzy, więc zmuszenie mnie do sprawdzenia marnuje mój czas. Bezpieczniejsze alternatywy osiągają swoje bezpieczeństwo, zmuszając mnie do prawidłowego radzenia sobie z potencjalnym stanem awarii, ale często nie chcę tego robić, chcę po prostu zacząć.

Jack Aidley
źródło
13
I tutaj podałeś przykład tego, co jest dokładnie nie tak z zerami. Prawidłowo zaimplementowana funkcja „openfile” powinna zgłosić wyjątek (dla brakującego pliku), który zatrzymałby wykonanie w tym miejscu z dokładnym wyjaśnieniem tego, co się stało. Zamiast tego, jeśli zwróci null, propaguje dalej (do for line in file) i zgłasza bezsensowny wyjątek odniesienia zerowego, co jest OK dla tak prostego programu, ale powoduje rzeczywiste problemy z debugowaniem w znacznie bardziej złożonych systemach. Gdyby nie istniały wartości zerowe, projektant „pliku otwartego” nie byłby w stanie popełnić tego błędu.
mrpyo
2
+1 za „Ponieważ języki programowania są na ogół zaprojektowane tak, aby były praktycznie użyteczne, a nie technicznie poprawne”
Martin Ba
2
Każdy typ opcji, który znam, pozwala wykonać błąd zerowania za pomocą jednego krótkiego dodatkowego wywołania metody (przykład Rust:) let file = something(...).unwrap(). W zależności od POV jest to prosty sposób na nieobsługiwanie błędów lub zwięzłe twierdzenie, że nie może wystąpić wartość null. Zmarnowany czas jest minimalny, a Ty oszczędzasz czas w innych miejscach, ponieważ nie musisz zastanawiać się, czy coś może być zerowe. Kolejną zaletą (która może być warta dodatkowego połączenia) jest to, że jawnie ignorujesz przypadek błędu; kiedy się nie powiedzie, nie ma wątpliwości, co poszło nie tak i gdzie należy dokonać poprawki.
4
@mrpyo Nie wszystkie języki obsługują wyjątki i / lub obsługę wyjątków (a la try / catch). Można także nadużywać wyjątków - „wyjątek jako kontrola przepływu” jest powszechnym anty-wzorem. Ten scenariusz - plik nie istnieje - jest AFAIK najczęściej cytowanym przykładem tego anty-wzorca. Wygląda na to, że zastępujesz jedną złą praktykę inną.
David
8
@mrpyo if file exists { open file }cierpi z powodu wyścigu. Jedynym niezawodnym sposobem sprawdzenia, czy otwarcie pliku się powiedzie, jest próba otwarcia go.
4

Istnieją jasne, praktyczne zastosowania wskaźnika NULL(lub nil, lub Nil, lub null, Nothinglub jakkolwiek to się nazywa w preferowanym języku) wskaźnika.

W przypadku języków, które nie mają systemu wyjątków (np. C), wskaźnik zerowy może być używany jako znak błędu, kiedy wskaźnik powinien zostać zwrócony. Na przykład:

char *buf = malloc(20);
if (!buf)
{
    perror("memory allocation failed");
    exit(1);
}

Tutaj NULLzwrócony z malloc(3)służy jako znacznik niepowodzenia.

W przypadku argumentów metody / funkcji może wskazywać użycie wartości domyślnej dla argumentu lub zignorować argument wyjściowy. Przykład poniżej.

Nawet dla tych języków z mechanizmem wyjątku wskaźnik zerowy może być używany jako wskaźnik błędu miękkiego (czyli błędów, które można odzyskać), szczególnie gdy obsługa wyjątków jest kosztowna (np. Cel-C):

NSError *err = nil;
NSString *content = [NSString stringWithContentsOfURL:sourceFile
                                         usedEncoding:NULL // This output is ignored
                                                error:&err];
if (!content) // If the object is null, we have a soft error to recover from
{
    fprintf(stderr, "error: %s\n", [[err localizedDescription] UTF8String]);
    if (!error) // Check if the parent method ignored the error argument
        *error = err;
    return nil; // Go back to parent layer, with another soft error.
}

W tym przypadku błąd miękki nie powoduje awarii programu, jeśli nie zostanie złapany. Eliminuje to szalone try-catch, takie jak Java, i ma lepszą kontrolę nad przebiegiem programu, ponieważ miękkie błędy nie przeszkadzają (a kilku pozostałych trudnych wyjątków zwykle nie można odzyskać i nie przechwycić)

Maxthon Chan
źródło
5
Problem polega na tym, że nie ma sposobu na odróżnienie zmiennych, które nigdy nie powinny zawierać nullod tych, które powinny. Na przykład, jeśli chcę nowy typ, który zawiera 5 wartości w Javie, mógłbym użyć wyliczenia, ale otrzymuję typ, który może przechowywać 6 wartości (5 chciałem + null). To wada w systemie typów.
Doval
@Doval Jeśli tak jest, po prostu przypisz NULL znaczenie (lub jeśli masz wartość domyślną, potraktuj to jako synonim wartości domyślnej) lub użyj NULL (która nigdy nie powinna pojawić się na pierwszym miejscu) jako znacznika miękkiego błędu (tj. błąd, ale przynajmniej jeszcze nie upaść)
Maxthon Chan
1
@MaxtonChan Nullmożna przypisać znaczenie tylko wtedy, gdy wartości typu nie zawierają danych (np. Wartości wyliczeniowe). Gdy tylko twoje wartości staną się bardziej skomplikowane (np. Struct), nullnie można przypisać znaczenia, które ma sens dla tego typu. Nie ma sposobu, aby użyć nulljako struktury lub listy. I znowu problem z użyciem nulljako sygnału błędu polega na tym, że nie jesteśmy w stanie stwierdzić, co może zwrócić wartość null lub przyjąć wartość null. Każda zmienna w twoim programie może być, nullchyba że bardzo skrupulatnie sprawdzasz każdy nullprzed każdym użyciem, czego nikt nie robi.
Doval
1
@Doval: Nie byłoby szczególnych nieodłącznych trudności w uznaniu niezmiennego typu odniesienia nullza użyteczną wartość domyślną (np. Posiadanie domyślnej wartości stringbehave jako pustego łańcucha, tak jak to miało miejsce w poprzednim Common Object Model). Jedyne, co byłoby konieczne, to używanie języków callzamiast callvirtwywoływania członków niebędących wirtualnymi.
supercat
@ superupat To dobra uwaga, ale czy teraz nie musisz dodawać obsługi do rozróżniania typów niezmiennych i nieodmiennych? Nie jestem pewien, jak proste jest dodanie do języka.
Doval
4

Istnieją dwa powiązane, ale nieco różne problemy:

  1. Czy nullw ogóle powinien istnieć? A może powinieneś zawsze używać Maybe<T>wartości null?
  2. Czy wszystkie odniesienia powinny być zerowane? Jeśli nie, która powinna być domyślna?

    Konieczność jawnego zadeklarowania dopuszczalnego typu zerowania jako string?lub podobnego pozwoliłoby uniknąć większości (ale nie wszystkich) nullprzyczyn problemów , nie będąc zbyt różnym od tego, do czego są przyzwyczajeni programiści.

Zgadzam się przynajmniej z tobą, że nie wszystkie odniesienia powinny być zerowane. Ale unikanie wartości null nie jest pozbawione złożoności:

.NET inicjuje wszystkie pola, default<T>zanim będzie można uzyskać do nich dostęp za pomocą kodu zarządzanego. Oznacza to, że dla potrzebnych typów referencji nulllub czegoś równoważnego te typy wartości mogą być inicjowane do pewnego rodzaju zera bez uruchamiania kodu. Chociaż oba mają poważne wady, prostota defaultinicjalizacji mogła przeważyć te wady.

  • Na przykład pola można obejść, wymagając inicjalizacji pól przed wystawieniem thiswskaźnika na kod zarządzany. Spec # poszedł tą drogą, używając innej składni niż tworzenie łańcuchów konstruktorów w porównaniu z C #.

  • W przypadku pól statycznych upewnienie się, że jest to trudniejsze, chyba że nałożysz silne ograniczenia na rodzaj kodu, który może działać w inicjalizatorze pól, ponieważ nie możesz po prostu ukryć thiswskaźnika.

  • Jak zainicjować tablice typów referencyjnych? Zastanów się, List<T>która jest wspierana przez tablicę o pojemności większej niż długość. Pozostałe elementy muszą mieć pewną wartość.

Innym problemem jest to, że nie pozwala na podobne metody bool TryGetValue<T>(key, out T value), która zwraca default(T)jako valuejeśli nie znajdą niczego. Chociaż w tym przypadku łatwo jest argumentować, że parametr out jest złym projektem, a ta metoda powinna zwrócić związek rozróżniający lub może zamiast tego.

Wszystkie te problemy można rozwiązać, ale nie jest to tak proste, jak „zabrania zerowania i wszystko jest w porządku”.

CodesInChaos
źródło
List<T>Jest IMHO najlepszym przykładem, ponieważ wymagałoby to, że albo każdy Tma wartość domyślną, że każdy element w sklepie podkładowej być Maybe<T>z dodatkowym „IsValid” pola, nawet gdy Tjest Maybe<U>lub że kod na List<T>zachowują się różnie w zależności od od tego, czy Tsam jest typem zerowalnym. Inicjowanie T[]elementów do wartości domyślnej uważałbym za najmniej złe z tych wyborów, ale oczywiście oznacza to, że elementy muszą mieć wartość domyślną.
supercat
Rdza następuje po punkcie 1 - w ogóle nie ma wartości zerowej. Ceylon podąża za punktem 2 - domyślnie inny niż null. Odwołania, które mogą mieć wartość NULL, są jawnie deklarowane za pomocą typu unii, który zawiera odwołanie lub NULL, ale NULL nigdy nie może być wartością zwykłego odwołania. W rezultacie język jest całkowicie bezpieczny i nie ma wyjątku NullPointerException, ponieważ nie jest semantycznie możliwy.
Jim Balter
2

Najbardziej przydatne języki programowania pozwalają na zapisywanie i odczytywanie elementów danych w dowolnych sekwencjach, tak że często niemożliwe będzie ustalenie statyczne kolejności, w jakiej odczyty i zapisy wystąpią przed uruchomieniem programu. Istnieje wiele przypadków, w których kod przechowuje przydatne dane w każdym gnieździe przed ich odczytaniem, ale udowodnienie tego byłoby trudne. Dlatego często konieczne będzie uruchamianie programów, w których przynajmniej teoretycznie byłoby możliwe, aby kod próbował odczytać coś, co nie zostało jeszcze napisane z użyteczną wartością. Bez względu na to, czy jest to zgodne z prawem, nie ma ogólnego sposobu na powstrzymanie kodu przed podjęciem próby. Jedyne pytanie brzmi: co powinno się stać, kiedy to nastąpi.

Różne języki i systemy mają różne podejście.

  • Jednym z podejść byłoby stwierdzenie, że każda próba odczytania czegoś, co nie zostało napisane, spowoduje natychmiastowy błąd.

  • Drugim podejściem jest wymaganie od kodu dostarczenia pewnej wartości w każdej lokalizacji, zanim będzie można ją odczytać, nawet jeśli nie byłoby sposobu, aby przechowywana wartość była semantycznie użyteczna.

  • Trzecim podejściem jest po prostu zignorowanie problemu i pozwolić, aby cokolwiek wydarzyło się „naturalnie”, po prostu się wydarzyło.

  • Czwarte podejście mówi, że każdy typ musi mieć wartość domyślną, a każdy slot, który nie został napisany z niczym innym, będzie przyjmował wartość domyślną.

Podejście nr 4 jest znacznie bezpieczniejsze niż podejście nr 3 i ogólnie jest tańsze niż podejście nr 1 i 2. To pozostawia pytanie, jaka powinna być wartość domyślna dla typu odniesienia. W przypadku niezmiennych typów referencji w wielu przypadkach sensowne byłoby zdefiniowanie domyślnej instancji i powiedzenie, że domyślna dla dowolnej zmiennej tego typu powinna być referencją do tej instancji. W przypadku zmiennych typów odniesienia nie byłoby to jednak bardzo pomocne. Jeśli podjęta zostanie próba użycia zmiennego typu odwołania przed jego napisaniem, zasadniczo nie ma żadnego bezpiecznego sposobu działania poza pułapką w punkcie próby użycia.

Semantycznie rzecz biorąc, jeśli ktoś ma tablicę customerstypu Customer[20]i próbuje się Customer[4].GiveMoney(23)niczego nie zapisywać Customer[4], wykonanie będzie musiało zostać uwięzione. Można argumentować, że próba odczytu Customer[4]powinna od razu złapać pułapkę, zamiast czekać na próbę wykonania kodu GiveMoney, ale istnieje wystarczająca liczba przypadków, w których warto odczytać boks, dowiedzieć się, że nie zawiera on wartości, a następnie skorzystać z tego informacja, że ​​sama próba odczytu nie powiodła się, często byłaby poważnym utrapieniem.

Niektóre języki pozwalają określić, że niektóre zmienne nigdy nie powinny zawierać wartości null, a każda próba zapisania wartości null powinna spowodować natychmiastową pułapkę. To przydatna funkcja. Zasadniczo jednak każdy język, który pozwala programistom tworzyć tablice referencji, będzie musiał albo dopuszczać możliwość zerowania elementów tablicy, albo wymusi inicjalizację elementów tablicy na dane, które nie mogą być znaczące.

supercat
źródło
Nie Maybe/ Optiontyp rozwiązać problem z # 2, ponieważ jeśli nie mają wartość odniesienia jeszcze ale będzie mieć w przyszłości, można po prostu przechowywać Nothingw sposób Maybe <Ref type>?
Doval
@Doval: Nie, nie rozwiązałoby to problemu - przynajmniej nie bez ponownego wprowadzenia zerowych referencji. Czy „nic” powinno działać jak członek tego typu? Jeśli tak, to jaki? A może powinien to stanowić wyjątek? W takim razie, co może być lepszego od zwykłego null/ właściwego używania ?
cHao
@Doval: Czy typem podkładu powinno List<T>być a T[]czy a Maybe<T>? Co z rodzajem podkładu a List<Maybe<T>>?
supercat
@ superuper Nie jestem pewien, jak Maybema to sens dla typu kopii zapasowej, Listponieważ Maybezawiera jedną wartość. Miałeś na myśli Maybe<T>[]?
Doval
@cHao Nothingmożna przypisać tylko do wartości typu Maybe, więc nie jest to tak jak przypisanie null. Maybe<T>i Tsą dwoma odrębnymi typami.
Doval