W jaki sposób języki z typami Może zamiast wartości zerowych obsługują warunki brzegowe?


53

Eric Lippert podkreślił bardzo interesujący punkt w swojej dyskusji na temat tego, dlaczego C # używa nullraczej 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?


Sądzę, że jeśli Może jest częścią tego języka, być może jest on implementowany wewnętrznie za pomocą wskaźnika zerowego i jest to po prostu cukier składniowy. Ale nie sądzę, żeby jakikolwiek język tak to robił.
panzi

1
@panzi: Ceylon wykorzystuje pisanie wrażliwe na przepływ, aby odróżnić Type?(być może) od Type(nie zerowy)
Lukas Eder

1
@RobertHarvey Czy w Stack Exchange nie ma już przycisku „miłego pytania”?
user253751

2
@panzi To niezła i poprawna optymalizacja, ale to nie pomaga w rozwiązaniu tego problemu: kiedy coś nie jest Maybe T, nie może tak być Nonei dlatego nie można zainicjować jego przechowywania do wskaźnika zerowego.

@immibis: Już to pchnąłem. Dostajemy tutaj cenne kilka dobrych pytań; Myślałem, że ten zasługuje na komentarz.
Robert Harvey

Odpowiedzi:


45

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:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

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:

  • Brak wartości domyślnych takich jak nulllub gwarantowana inicjalizacja zmiennych składowych.
  • Różnica między deklaracją a cesją. Zmuszenie do natychmiastowego przypisania zmiennych (np. Za pomocą letinstrukcji widocznej w językach funkcjonalnych) jest łatwe, aby wymusić gwarantowaną inicjalizację - ale ogranicza język na inne sposoby.
  • Specyficzny smak destruktorów jako metody wywoływanej przez środowisko wykonawcze języka.

Ł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:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

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.


Istnieje również inny powód, dla którego finalizator musi sobie poradzić z nulls: kolejność finalizacji nie jest gwarantowana, ze względu na możliwość cykli odniesienia. Ale wydaje mi się, że twój FINALIZEprojekt rozwiązuje również to: jeśli foozostał już sfinalizowany, jego FINALIZEsekcja po prostu nie będzie działać.
svick

14

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 robi self.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

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

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.


7

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 Nothingw 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ń, Maybektó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 a Maybe, 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:

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?

Tak. Inti Maybe Intsą dwoma całkowicie oddzielnymi typami. Znalezienie Nothingw równinie Intbyłoby porównywalne do znalezienia ciągu „ryba” w Int32.

A co z konstruktorem obiektu o niepisającym polu typu referencyjnego?

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.

A co z finalizatorem takiego obiektu, w którym obiekt jest finalizowany, ponieważ kod, który miał wypełnić referencję, zwrócił wyjątek?

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 Maybetypu danych do reprezentowania wartości zerowych. Może zdefiniowano typ danych algabrycznych w następujący sposób:

data Maybe a = Just a | Nothing

Dla tych z was, którzy nie znają Haskella, przeczytaj to jako „A Maybejest albo a, Nothingalbo a Just a”. Konkretnie:

  • Maybejest konstruktorem typu : można go traktować (niepoprawnie) jako klasę ogólną (gdzie ajest zmienna typu). Analogia C # jest class Maybe<a>{}.
  • Justjest konstruktorem wartości : jest to funkcja, która pobiera jeden argument typu ai zwraca wartość typu, Maybe aktóra zawiera wartość. Więc kod x = Just 17jest analogiczny do int? x = 17;.
  • Nothingjest innym konstruktorem wartości, ale nie przyjmuje żadnych argumentów, a Maybezwracana wartość nie jest inna niż „Nic”. x = Nothingjest analogiczny do int? x = null;(zakładając, że ograniczyliśmy się aw Haskell Int, co można zrobić pisząc x = Nothing :: Maybe Int).

Teraz, gdy podstawy tego Maybetypu 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:

ReferenceType x;

W Haskell nie jest to możliwe. Nie ma problemów z inicjowaniem wartości, nullponieważ 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:

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

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 douwaga wyglądająca na konieczną jest po prostu cukrem syntaktycznym służącym do przekazywania >>=operatorowi anonimowych funkcji . Wszystkie funkcje mają postać:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

Gdzie body-expressionmoż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ć Intdo Double? Użyj fromIntegralfunkcji). Jedyne, co może mieć niepoprawną wartość występującą w czasie wykonywania, to użycie Prelude.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):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

( fooi bartak naprawdę są tutaj funkcjami dostępowymi do anonimowych pól zamiast rzeczywistych pól, ale możemy zignorować ten szczegół).

Konstruktor NotSoBrokenwartości nie jest w stanie podjąć żadnych działań innych niż wykonanie a Fooi a Bar(które nie mają wartości zerowej) i wykonanie NotSoBrokenz 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 Brokenzawsze kończy się niepowodzeniem. Nie ma sposobu na złamanie NotSoBrokenkonstruktora wartości w podobny sposób (po prostu nie ma gdzie napisać kodu), ale możemy stworzyć funkcję fabryczną, która jest podobnie wadliwa.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(pierwsza linia jest deklaracją podpis typ: makeNotSoBrokentrwa Fooi Barjako argumenty i produkuje Maybe NotSoBroken).

Typem zwrotnym musi być, Maybe NotSoBrokena nie tylko NotSoBrokendlatego, że powiedzieliśmy mu, aby to oszacował Nothing, co jest konstruktorem wartości Maybe. 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, useNotSoBrokenktóra oczekuje NotSoBrokenjako argument:

useNotSoBroken :: NotSoBroken -> Whatever

( useNotSoBrokenprzyjmuje NotSoBrokenjako argument i tworzy a Whatever).

I użyj go w ten sposób:

useNotSoBroken (makeNotSoBroken)

W większości języków takie zachowanie może powodować wyjątek wskaźnika zerowego. W Haskell typy nie pasują do siebie: makeNotSoBrokenzwraca a Maybe NotSoBroken, ale useNotSoBrokenoczekuje NotSoBroken. Te typy nie są zamienne, a kod się nie kompiluje.

Aby obejść ten problem, możemy użyć caseinstrukcji do rozgałęzienia na podstawie struktury Maybewartości (za pomocą funkcji zwanej dopasowaniem wzorca ):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

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:

  • Najpierw makeNotSoBrokenjest oceniany, co gwarantuje uzyskanie wartości typu Maybe NotSoBroken.
  • caseOświadczenie kontroluje strukturę tej wartości.
  • Jeśli wartość jest równa Nothing, analizowany jest kod „obsłuż tutaj sytuację”.
  • Jeśli zamiast tego wartość pasuje do Justwartości, druga gałąź jest wykonywana. Zwróć uwagę, jak klauzula dopasowania jednocześnie identyfikuje wartość jako Justkonstrukcję i wiąże swoje NotSoBrokenpole wewnętrzne z nazwą (w tym przypadku x). xmogą być następnie używane jak normalna NotSoBrokenwartość.

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.


TL; DR powinno być na górze :)
andrew.fox

@ andrew.fox Dobra uwaga. Będę edytować.
ApproachingDarknessFish

0

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 nulllub Maybe::noneniezainicjowanie 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 # nullwartość jest prawie równoważna Maybe::none. Możesz przypisać nulltylko zmienne i elementy obiektu, które na poziomie typu są zadeklarowane jako nullable :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Nie różni się niczym od następującego fragmentu kodu:

Maybe<String> optionalString = getOptionalString();

Podsumowując, nie widzę, w jaki sposób zerowanie jest w jakikolwiek sposób przeciwne do Maybetypów. Sugerowałbym nawet, że C # wkradł się w swoim własnym Maybetypie i nazwał go Nullable<T>.

Dzięki metodom przedłużania można nawet łatwo oczyścić Nullable, aby postępować zgodnie z monadycznym wzorem:

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );

2
co to znaczy „konstruktor albo całkowicie się kończy, albo nie”? Na przykład w Javie inicjowanie (nie-końcowe) pola w konstruktorze nie jest chronione przed wyścigiem danych - czy to kwalifikuje się jako pełne uzupełnienie, czy nie?
komar

@gnat: co rozumiesz przez „Na przykład w Javie inicjowanie (nie-końcowe) pola w konstruktorze nie jest chronione przed wyścigiem danych”. O ile nie zrobisz czegoś spektakularnie złożonego z wykorzystaniem wielu wątków, szanse na warunki wyścigowe w konstruktorze są (lub powinny być) prawie niemożliwe. Nie można uzyskać dostępu do pola nieskonstruowanego obiektu, z wyjątkiem wewnątrz konstruktora obiektów. A jeśli konstrukcja się nie powiedzie, nie masz odniesienia do obiektu.
Roland Tepp

Duża różnica pomiędzy nullniejawnym członkiem każdego typu i Maybe<T>tym, że będzie Maybe<T>, możesz też mieć just T, który nie ma żadnej wartości domyślnej.
svick

Podczas tworzenia tablic często nie będzie możliwe określenie wartości użytecznych dla wszystkich elementów bez konieczności ich odczytu, ani statyczna weryfikacja, czy żaden element nie został odczytany bez wartości użytkowej. Najlepsze, co można zrobić, to zainicjować elementy tablicy w taki sposób, aby można je było uznać za bezużyteczne.
supercat

@svick: W języku C # (który był językiem omawianym przez OP), nullnie jest niejawnym członkiem każdego typu. Aby nullbyć wartością lebal, musisz jawnie zdefiniować typ, który ma zostać dopuszczony do zerowania, co sprawia, że T?(cukier składniowy dla Nullable<T>) jest zasadniczo równoważny Maybe<T>.
Roland Tepp

-3

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, floatsstaje się 0,0, boolsstaje 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 .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}

2
pytanie dotyczyło języków z typami Może
gnat

3
Referencje stają się zerowe ” - cała przesłanka tego pytania jest taka, że ​​nie mamy null, a jedynym sposobem wskazania braku wartości jest użycie Maybetypu (znanego również jako Option), 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ć nadal null.
amon

Podczas gdy c ++ nie ma jawnie typów Być może, rzeczy takie jak std :: shared_ptr <T> są wystarczająco blisko, że myślę, że nadal jest istotne, że c ++ obsługuje przypadek, w którym inicjalizacja zmiennych może nastąpić „poza zakresem” konstruktora, i jest w rzeczywistości wymagany dla typów referencyjnych (&), ponieważ nie mogą mieć wartości null.
FryGuy
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.