Eric Lippert podkreślił bardzo interesujący punkt w swojej dyskusji na temat tego, dlaczego C # używa null
raczej niż Maybe<T>
typu :
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.
To było trochę otwierające oczy. Związane z tym koncepcje mnie interesują i bawiłem się kompilatorami i systemami typu, ale nigdy nie myślałem o tym scenariuszu. W jaki sposób języki, które mają typ Może zamiast pustych uchwytów, takie jak inicjalizacja i odzyskiwanie po błędzie, w których rzekomo gwarantowane niepuste odwołanie nie jest w rzeczywistości w prawidłowym stanie?
źródło
Type?
(być może) odType
(nie zerowy)Maybe T
, nie może tak byćNone
i dlatego nie można zainicjować jego przechowywania do wskaźnika zerowego.Odpowiedzi:
Ten cytat wskazuje na problem, który występuje, jeśli deklaracja i przypisanie identyfikatorów (tutaj: członkowie instancji) są od siebie oddzielne . Jako szybki szkic pseudokodu:
Scenariusz jest teraz taki, że podczas budowy instancji zostanie zgłoszony błąd, więc budowa zostanie przerwana, zanim instancja zostanie w pełni zbudowana. Ten język oferuje metodę destruktora, która będzie działać przed zwolnieniem pamięci, np. W celu ręcznego zwolnienia zasobów innych niż pamięć. Musi być również uruchamiany na częściowo zbudowanych obiektach, ponieważ ręcznie zarządzane zasoby mogły już zostać przydzielone przed przerwaniem budowy.
Z zerami niszczyciel mógł sprawdzić, czy zmienna została przypisana podobnie
if (foo != null) foo.cleanup()
. Bez wartości zerowych obiekt jest teraz w nieokreślonym stanie - jaka jest jego wartośćbar
?Jednak ten problem występuje z powodu połączenia trzech aspektów:
null
lub gwarantowana inicjalizacja zmiennych składowych.let
instrukcji widocznej w językach funkcjonalnych) jest łatwe, aby wymusić gwarantowaną inicjalizację - ale ogranicza język na inne sposoby.Łatwo jest wybrać inny projekt, który nie wykazuje tych problemów, na przykład zawsze łącząc deklarację z przypisaniem i mając język oferujący wiele bloków finalizatora zamiast jednej metody finalizacji:
Zatem nie ma problemu z brakiem wartości null, ale z kombinacją zestawu innych funkcji z brakiem wartości null.
Interesujące pytanie brzmi teraz, dlaczego C # wybrał jeden projekt, ale nie drugi. Tutaj kontekst cytatu wymienia wiele innych argumentów na wartość zerową w języku C #, które można w większości podsumować jako „znajomość i zgodność” - i są to dobre powody.
źródło
null
s: kolejność finalizacji nie jest gwarantowana, ze względu na możliwość cykli odniesienia. Ale wydaje mi się, że twójFINALIZE
projekt rozwiązuje również to: jeślifoo
został już sfinalizowany, jegoFINALIZE
sekcja po prostu nie będzie działać.Taki sam sposób, w jaki gwarantujesz, że wszelkie inne dane są w prawidłowym stanie.
Można uporządkować semantykę i sterować przepływem tak, aby nie można było mieć zmiennej / pola jakiegoś typu bez pełnego utworzenia dla niego wartości. Zamiast tworzyć obiekt i pozwalać konstruktorowi przypisywać „początkowe” wartości do jego pól, można utworzyć obiekt tylko poprzez określenie wartości dla wszystkich jego pól jednocześnie. Zamiast deklarować zmienną, a następnie przypisywać wartość początkową, można wprowadzić zmienną tylko z inicjalizacją.
Na przykład w Rust tworzysz obiekt typu struct,
Point { x: 1, y: 2 }
zamiast pisać konstruktor, który to robiself.x = 1; self.y = 2;
. Oczywiście może to kolidować ze stylem języka, który masz na myśli.Innym uzupełniającym podejściem jest wykorzystanie analizy żywotności, aby zapobiec dostępowi do pamięci przed jej inicjalizacją. Pozwala to na zadeklarowanie zmiennej bez natychmiastowej jej inicjalizacji, o ile jest ona do udowodnienia przypisana przed pierwszym odczytem. Może również wychwycić niektóre przypadki awarii, takie jak
Technicznie można również zdefiniować dowolną domyślną inicjalizację dla obiektów, np. Wyzerować wszystkie pola numeryczne, utworzyć puste tablice dla pól tablic itp., Ale jest to raczej arbitralne, mniej wydajne niż inne opcje i może maskować błędy.
źródło
Oto, w jaki sposób robi to Haskell: (nie do końca sprzeczne z twierdzeniami Lipperta, ponieważ Haskell nie jest językiem zorientowanym obiektowo).
OSTRZEŻENIE: długa, wyczerpująca odpowiedź od poważnego fan-fan Haskella.
TL; DR
Ten przykład pokazuje dokładnie, jak różni się Haskell od C #. Zamiast delegować logistykę budowy konstrukcji do konstruktora, należy ją obsłużyć w otaczającym kodzie. Nie ma możliwości, aby wartość zerowa (lub
Nothing
w Haskell) pojawiła się w miejscu, w którym oczekujemy wartości innej niż zerowa, ponieważ wartości zerowe mogą występować tylko w ramach wywoływanych specjalnych typów opakowań,Maybe
których nie można zamieniać z / bezpośrednio zamieniającymi na zwykłe, inne niż typy zerowalne. Aby użyć wartości, która stała się zerowalna przez zawinięcie jej w aMaybe
, musimy najpierw wyodrębnić wartość za pomocą dopasowania wzorca, co zmusza nas do przekierowania przepływu sterowania do gałęzi, w której wiemy na pewno, że mamy wartość inną niż null.W związku z tym:
Tak.
Int
iMaybe Int
są dwoma całkowicie oddzielnymi typami. ZnalezienieNothing
w równinieInt
byłoby porównywalne do znalezienia ciągu „ryba” wInt32
.To nie problem: konstruktory wartości w Haskell nie mogą nic zrobić, tylko wziąć podane im wartości i złożyć je w całość. Cała logika inicjalizacji ma miejsce przed wywołaniem konstruktora.
W Haskell nie ma finalistów, więc tak naprawdę nie mogę się tym zająć. Jednak moja pierwsza odpowiedź wciąż trwa.
Pełna odpowiedź :
Haskell nie ma wartości null i używa
Maybe
typu danych do reprezentowania wartości zerowych. Może zdefiniowano typ danych algabrycznych w następujący sposób:Dla tych z was, którzy nie znają Haskella, przeczytaj to jako „A
Maybe
jest albo a,Nothing
albo aJust a
”. Konkretnie:Maybe
jest konstruktorem typu : można go traktować (niepoprawnie) jako klasę ogólną (gdziea
jest zmienna typu). Analogia C # jestclass Maybe<a>{}
.Just
jest konstruktorem wartości : jest to funkcja, która pobiera jeden argument typua
i zwraca wartość typu,Maybe a
która zawiera wartość. Więc kodx = Just 17
jest analogiczny doint? x = 17;
.Nothing
jest innym konstruktorem wartości, ale nie przyjmuje żadnych argumentów, aMaybe
zwracana wartość nie jest inna niż „Nic”.x = Nothing
jest analogiczny doint? x = null;
(zakładając, że ograniczyliśmy sięa
w HaskellInt
, co można zrobić piszącx = Nothing :: Maybe Int
).Teraz, gdy podstawy tego
Maybe
typu nie są na przeszkodzie, w jaki sposób Haskell unika problemów omawianych w pytaniu PO?Haskell naprawdę różni się od większości omawianych dotychczas języków, więc zacznę od wyjaśnienia kilku podstawowych zasad językowych.
Po pierwsze, w Haskell wszystko jest niezmienne . Wszystko. Nazwy odnoszą się do wartości, a nie do miejsc w pamięci, w których można przechowywać wartości (samo to jest ogromnym źródłem eliminacji błędów). W przeciwieństwie do C #, gdzie deklaracja zmiennej i przypisanie to dwie odrębne operacje, w wartościach Haskell są tworzone poprzez określenie ich wartości (np
x = 15
,y = "quux"
,z = Nothing
), co może nigdy się nie zmieniają. Dlatego kod taki jak:W Haskell nie jest to możliwe. Nie ma problemów z inicjowaniem wartości,
null
ponieważ wszystko musi zostać jawnie zainicjowane na wartość, aby mogła istnieć.Po drugie, Haskell nie jest językiem zorientowanym obiektowo : jest to język czysto funkcjonalny , więc nie ma obiektów w ścisłym tego słowa znaczeniu. Zamiast tego istnieją po prostu funkcje (konstruktory wartości), które pobierają swoje argumenty i zwracają połączoną strukturę.
Następnie absolutnie nie ma kodu stylu imperatywnego. Rozumiem przez to, że większość języków ma podobny wzór:
Zachowanie programu jest wyrażone jako seria instrukcji. W językach zorientowanych obiektowo deklaracje klas i funkcji również odgrywają ogromną rolę w przepływie programu, ale w istocie „mięso” wykonania programu przybiera formę szeregu instrukcji do wykonania.
W Haskell nie jest to możliwe. Zamiast tego przebieg programu jest podyktowany wyłącznie funkcjami łańcuchowymi. Nawet
do
uwaga wyglądająca na konieczną jest po prostu cukrem syntaktycznym służącym do przekazywania>>=
operatorowi anonimowych funkcji . Wszystkie funkcje mają postać:Gdzie
body-expression
może być coś, co daje wartość. Oczywiście dostępnych jest więcej funkcji składniowych, ale głównym punktem jest całkowity brak sekwencji instrukcji.Wreszcie, i chyba najważniejsze, system typowania Haskella jest niezwykle surowy. Gdybym musiał podsumować centralną filozofię projektowania systemu typów Haskell, powiedziałbym: „Spraw, aby jak najwięcej rzeczy popsuło się w czasie kompilacji, tak aby jak najmniej popsuła się w czasie wykonywania”. Nie ma żadnych ukrytych konwersji (chcesz awansować
Int
doDouble
? UżyjfromIntegral
funkcji). Jedyne, co może mieć niepoprawną wartość występującą w czasie wykonywania, to użyciePrelude.undefined
(które najwyraźniej musi tam być i nie można go usunąć ).Mając to na uwadze, spójrzmy na „zepsuty” przykład amona i spróbuj ponownie wyrazić ten kod w Haskell. Po pierwsze, deklaracja danych (przy użyciu składni rekordu dla nazwanych pól):
(
foo
ibar
tak naprawdę są tutaj funkcjami dostępowymi do anonimowych pól zamiast rzeczywistych pól, ale możemy zignorować ten szczegół).Konstruktor
NotSoBroken
wartości nie jest w stanie podjąć żadnych działań innych niż wykonanie aFoo
i aBar
(które nie mają wartości zerowej) i wykonanieNotSoBroken
z nich. Nie ma miejsca, aby umieścić kod rozkazujący, a nawet ręcznie przypisać pola. Cała logika inicjalizacji musi mieć miejsce gdzie indziej, najprawdopodobniej w dedykowanej funkcji fabrycznej.W tym przykładzie konstrukcja
Broken
zawsze kończy się niepowodzeniem. Nie ma sposobu na złamanieNotSoBroken
konstruktora wartości w podobny sposób (po prostu nie ma gdzie napisać kodu), ale możemy stworzyć funkcję fabryczną, która jest podobnie wadliwa.(pierwsza linia jest deklaracją podpis typ:
makeNotSoBroken
trwaFoo
iBar
jako argumenty i produkujeMaybe NotSoBroken
).Typem zwrotnym musi być,
Maybe NotSoBroken
a nie tylkoNotSoBroken
dlatego, że powiedzieliśmy mu, aby to oszacowałNothing
, co jest konstruktorem wartościMaybe
. Typy po prostu nie pasowałyby do siebie, gdybyśmy napisali coś innego.Poza tym, że jest absolutnie bezcelowa, ta funkcja nawet nie spełnia swojego prawdziwego celu, co zobaczymy, kiedy będziemy próbować jej użyć. Utwórzmy funkcję o nazwie,
useNotSoBroken
która oczekujeNotSoBroken
jako argument:(
useNotSoBroken
przyjmujeNotSoBroken
jako argument i tworzy aWhatever
).I użyj go w ten sposób:
W większości języków takie zachowanie może powodować wyjątek wskaźnika zerowego. W Haskell typy nie pasują do siebie:
makeNotSoBroken
zwraca aMaybe NotSoBroken
, aleuseNotSoBroken
oczekujeNotSoBroken
. Te typy nie są zamienne, a kod się nie kompiluje.Aby obejść ten problem, możemy użyć
case
instrukcji do rozgałęzienia na podstawie strukturyMaybe
wartości (za pomocą funkcji zwanej dopasowaniem wzorca ):Oczywiście ten fragment kodu musi zostać umieszczony w jakimś kontekście, aby go skompilować, ale pokazuje podstawy, w jaki sposób Haskell obsługuje wartości null. Oto wyjaśnienie powyższego kodu krok po kroku:
makeNotSoBroken
jest oceniany, co gwarantuje uzyskanie wartości typuMaybe NotSoBroken
.case
Oświadczenie kontroluje strukturę tej wartości.Nothing
, analizowany jest kod „obsłuż tutaj sytuację”.Just
wartości, druga gałąź jest wykonywana. Zwróć uwagę, jak klauzula dopasowania jednocześnie identyfikuje wartość jakoJust
konstrukcję i wiąże swojeNotSoBroken
pole wewnętrzne z nazwą (w tym przypadkux
).x
mogą być następnie używane jak normalnaNotSoBroken
wartość.Tak więc dopasowanie wzorca zapewnia potężne narzędzie do egzekwowania bezpieczeństwa typu, ponieważ struktura obiektu jest nierozerwalnie związana z rozgałęzieniem kontroli.
Mam nadzieję, że to zrozumiałe wytłumaczenie. Jeśli to nie ma sensu, wskocz do Learn You A Haskell For Great Good! , jeden z najlepszych samouczków językowych online, jakie kiedykolwiek czytałem. Mam nadzieję, że zobaczysz to samo piękno w tym języku, co ja.
źródło
Myślę, że twój cytat to argument słomy.
Współczesne języki (w tym C #) gwarantują, że konstruktor albo całkowicie wypełni, albo nie.
Jeśli w konstruktorze występuje wyjątek i obiekt jest częściowo niezainicjowany, posiadanie
null
lubMaybe::none
niezainicjowanie stanu nie ma żadnej różnicy w kodzie destruktora.Po prostu będziesz musiał sobie z tym poradzić. Gdy istnieją zasoby zewnętrzne do zarządzania, musisz jawnie nimi zarządzać w jakikolwiek sposób. Języki i biblioteki mogą pomóc, ale trzeba będzie się nad tym zastanowić.
Btw: W języku C #
null
wartość jest prawie równoważnaMaybe::none
. Możesz przypisaćnull
tylko zmienne i elementy obiektu, które na poziomie typu są zadeklarowane jako nullable :Nie różni się niczym od następującego fragmentu kodu:
Podsumowując, nie widzę, w jaki sposób zerowanie jest w jakikolwiek sposób przeciwne do
Maybe
typów. Sugerowałbym nawet, że C # wkradł się w swoim własnymMaybe
typie i nazwał goNullable<T>
.Dzięki metodom przedłużania można nawet łatwo oczyścić Nullable, aby postępować zgodnie z monadycznym wzorem:
źródło
null
niejawnym członkiem każdego typu iMaybe<T>
tym, że będzieMaybe<T>
, możesz też mieć justT
, który nie ma żadnej wartości domyślnej.null
nie jest niejawnym członkiem każdego typu. Abynull
być wartością lebal, musisz jawnie zdefiniować typ, który ma zostać dopuszczony do zerowania, co sprawia, żeT?
(cukier składniowy dlaNullable<T>
) jest zasadniczo równoważnyMaybe<T>
.C ++ robi to, mając dostęp do inicjalizatora występującego przed treścią konstruktora. C # uruchamia domyślny inicjalizator przed treścią konstruktora, z grubsza przypisuje 0 do wszystkiego,
floats
staje się 0,0,bools
staje się fałszem, referencje stają się zerowe itp. W C ++ można uruchomić inny inicjator, aby mieć pewność, że typ referencji innej niż null nigdy nie ma wartości zerowej .źródło
null
, a jedynym sposobem wskazania braku wartości jest użycieMaybe
typu (znanego również jakoOption
), którego AFAIK C ++ nie ma w standardowa biblioteka. Brak wartości null pozwala nam zagwarantować, że pole będzie zawsze ważne jako właściwość systemu typów . Jest to silniejsza gwarancja niż ręczne upewnienie się, że nie istnieje ścieżka kodu w miejscu, w którym zmienna może być nadalnull
.