wskaźniki zerowe a wzorzec zerowy obiektu

27

Uznanie autorstwa: Wynikało to z powiązanego pytania P.SE

Moje doświadczenie jest w C / C ++, ale pracowałem sporo w Javie i obecnie koduję C #. Ze względu na moje tło C sprawdzanie wskaźników zakończonych i zwróconych jest używane, ale potwierdzam, że podważam mój punkt widzenia.

Niedawno widziałem wzmiankę o zerowym wzorzec obiektu, w którym chodzi o to, że obiekt jest zawsze zwracany. Przypadek normalny zwraca oczekiwany, zapełniony obiekt, a przypadek błędu zwraca pusty obiekt zamiast wskaźnika zerowego. Założeniem jest to, że funkcja wywołująca zawsze będzie miała jakiś obiekt, aby uzyskać do niego dostęp, a tym samym uniknąć naruszenia pamięci o zerowym dostępie.

  • Więc jakie są zalety / wady sprawdzania wartości zerowej w porównaniu do używania wzorca obiektu zerowego?

Widzę czystszy kod wywołujący za pomocą NOP, ale mogę również zobaczyć, gdzie stworzyłoby ukryte awarie, które w przeciwnym razie nie byłyby podnoszone. Wolę, aby moja aplikacja mocno zawiodła (inaczej wyjątek) podczas jej opracowywania, niż cicha pomyłka w ucieczce na wolność.

  • Czy wzorzec obiektu zerowego nie może mieć podobnych problemów, jak brak sprawdzania wartości zerowej?

Wiele obiektów, nad którymi pracowałem, zawierało własne obiekty lub pojemniki. Wygląda na to, że musiałbym mieć specjalny przypadek, aby zagwarantować, że wszystkie kontenery głównego obiektu mają własne puste obiekty. Wygląda na to, że może to być brzydkie z wieloma warstwami zagnieżdżania.

Społeczność
źródło
Nie „przypadek błędu”, ale „we wszystkich przypadkach prawidłowy obiekt”.
@ ThorbjørnRavnAndersen - dobry punkt, a ja zredagowałem pytanie, aby to odzwierciedlić. Daj mi znać, jeśli nadal mylę przesłankę NOP.
6
Krótka odpowiedź jest taka, że ​​„wzorzec obiektu zerowego” jest mylącą nazwą. Dokładniej nazywa się to „anty-wzorcem zerowego obiektu”. Zazwyczaj lepiej jest mieć „obiekt błędu” - prawidłowy obiekt, który implementuje cały interfejs klasy, ale każde jego użycie powoduje głośną, nagłą śmierć.
Jerry Coffin
1
@JerryCoffin w tej sprawie należy wskazać wyjątek. Chodzi o to, że nigdy nie można uzyskać wartości zerowej, a zatem nie trzeba sprawdzać wartości zerowej.
1
Nie podoba mi się ten „wzór”. Być może jest on jednak zakorzeniony w przeciążonych relacyjnych bazach danych, w których łatwiej jest odwoływać się do specjalnych „zerowych wierszy” w kluczach obcych podczas przemieszczania edytowalnych danych, przed ostateczną aktualizacją, gdy wszystkie klucze obce nie są całkowicie puste.

Odpowiedzi:

24

Nie należy używać Wzorca Null Object w miejscach, w których zwracany jest Null (lub Null Object), ponieważ wystąpiła katastrofalna awaria. W tych miejscach nadal zwracałbym zero. W niektórych przypadkach, jeśli nie ma odzyskiwania, równie dobrze możesz ulec awarii, ponieważ przynajmniej zrzut awaryjny wskaże dokładnie, gdzie wystąpił problem. W takich przypadkach, gdy dodasz własną obsługę błędów, nadal będziesz zabijać proces (znowu mówiłem o przypadkach, w których nie ma odzyskiwania), ale obsługa błędów będzie maskować bardzo ważne informacje, które dostarczyłby zrzut awaryjny.

Wzorzec zerowy obiektu jest bardziej odpowiedni dla miejsc, w których istnieje domyślne zachowanie, które można podjąć w przypadku, gdy obiekt nie zostanie znaleziony. Na przykład rozważ następujące kwestie:

User* pUser = GetUser( "Bob" );

if( pUser )
{
    pUser->SetAddress( "123 Fake St." );
}

Jeśli używasz NOP, napiszesz:

GetUser( "Bob" )->SetAddress( "123 Fake St." );

Zauważ, że zachowanie tego kodu brzmi „jeśli Bob istnieje, chcę zaktualizować jego adres”. Oczywiście, jeśli twoja aplikacja wymaga obecności Boba, nie chcesz po cichu odnieść sukcesu. Ale są przypadki, w których tego rodzaju zachowanie byłoby odpowiednie. A w takich przypadkach, czy NOP nie tworzy znacznie bardziej przejrzystego i zwięzłego kodu?

W miejscach, w których tak naprawdę nie można żyć bez Boba, chciałbym, aby GetUser () wyrzucił wyjątek aplikacji (tj. Brak naruszenia praw dostępu itp.), Który byłby obsługiwany na wyższym poziomie i zgłaszałby ogólną awarię operacji. W tym przypadku nie ma potrzeby NOP, ale nie ma też potrzeby jawnego sprawdzania NULL. IMO, sprawdzanie NULL, tylko powiększa kod i odbiera czytelność. Sprawdź, czy NULL jest nadal właściwym wyborem dla niektórych interfejsów, ale nie tak wiele, jak niektórzy ludzie myślą.

DXM
źródło
4
Zgadzam się z przypadkiem użycia wzorów zerowych obiektów. Ale po co zwracać wartość zerową w przypadku katastrofalnych awarii? Dlaczego nie rzucić wyjątków?
Apoorv Khurasia
2
@MonsterTruck: odwróć to. Kiedy piszę kod, sprawdzam poprawność większości danych wejściowych otrzymanych z komponentów zewnętrznych. Jednak między klasami wewnętrznymi, jeśli napiszę kod w taki sposób, że funkcja jednej klasy nigdy nie zwróci NULL, to po stronie wywołującej nie dodam kontroli zerowej, aby „być bardziej bezpiecznym”. Jedynym sposobem, w jaki ta wartość mogłaby być NULL, jest katastrofalny błąd logiczny w mojej aplikacji. W takim przypadku chcę, aby mój program zawiesił się dokładnie w tym miejscu, ponieważ wtedy otrzymuję ładny plik zrzutu awaryjnego i cofam stos i stan obiektu, aby zidentyfikować błąd logiczny.
DXM,
2
@ MonsterTruck: przez „odwróć to” miałem na myśli, nie zwracaj jawnie wartości NULL, gdy zidentyfikujesz błąd katastroficzny, ale oczekuj, że NULL wystąpi tylko z powodu jakiegoś nieprzewidzianego błędu katastroficznego.
DXM,
Rozumiem teraz, co miałeś na myśli przez katastrofalny błąd - coś, co powoduje, że sama alokacja obiektów kończy się niepowodzeniem. Dobrze? Edycja: Nie można zrobić @ DXM, ponieważ Twój pseudonim ma tylko 3 znaki.
Apoorv Khurasia
@MonsterTruck: dziwne na temat „@”. Wiem, że inni ludzie to robili wcześniej. Zastanawiam się, czy ostatnio poprawili stronę. To nie musi być alokacja. Jeśli napiszę klasę, a intencją interfejsu API jest zawsze zwracanie poprawnego obiektu, nie spowodowałbym, aby program wywołujący sprawdził wartość zerową. Ale katastrofalny błąd może być czymś w rodzaju błędu programisty (literówka, która spowodowała zwrócenie NULL), a może jakieś warunki rasowe, które wymazały obiekt przed czasem, lub może jakieś uszkodzenie stosu. Zasadniczo każda nieoczekiwana ścieżka kodu, która doprowadziła do zwrócenia NULL. Kiedy tak się dzieje, chcesz ...
DXM,
7

Więc jakie są zalety / wady sprawdzania wartości zerowej w porównaniu do używania wzorca obiektu zerowego?

Plusy

  • Kontrola zerowa jest lepsza, ponieważ rozwiązuje więcej przypadków. Nie wszystkie obiekty mają rozsądne zachowanie domyślne lub brak działania.
  • Kontrola zerowa jest bardziej solidna. Nawet obiekty z rozsądnymi wartościami domyślnymi są używane w miejscach, w których rozsądne wartości domyślne są nieprawidłowe. Kod powinien zawieść blisko głównej przyczyny, jeśli zawiedzie. Kod powinien oczywiście zawieść, jeśli zawiedzie.

Cons

  • Domyślne rozsądne ustawienia zwykle powodują czystszy kod.
  • Domyślne rozsądne skutkują zwykle mniej katastrofalnymi błędami, jeśli uda im się dostać na wolność.

Ten ostatni „pro” jest głównym wyróżnikiem (z mojego doświadczenia), kiedy należy zastosować każdy z nich. „Czy awaria powinna być głośna?”. W niektórych przypadkach chcesz, aby niepowodzenie było trudne i natychmiastowe; jeśli jakiś scenariusz, który nigdy nie powinien się wydarzyć, tak się dzieje. Jeśli nie znaleziono istotnego zasobu ... itd. W niektórych przypadkach nie masz nic przeciwko rozsądnemu domyślnemu ustawieniu, ponieważ tak naprawdę nie jest to błąd : uzyskanie wartości ze słownika, ale na przykład brakuje klucza.

Jak każda inna decyzja projektowa, istnieją wady i zalety w zależności od potrzeb.

Telastyn
źródło
1
W sekcji Plusy: Zgadzam się z pierwszym punktem. Druga kwestia to wina projektantów, ponieważ nawet zero może być użyte tam, gdzie nie powinno. Nie mówi nic złego na temat wzoru, tylko odzwierciedla programistę, który nieprawidłowo zastosował wzór.
Apoorv Khurasia
Co to jest bardziej katastrofalne niepowodzenie, niemożność wysłania pieniędzy lub wysłania (a tym samym nie posiadania) pieniędzy bez otrzymania ich przez odbiorcę i nieznania ich przed wyraźną kontrolą?
user470365 10.09 o
Wady: domyślne wartości Sane można wykorzystać do budowy nowych obiektów. Jeśli zwracany jest obiekt inny niż pusty, niektóre jego atrybuty można modyfikować podczas tworzenia innego obiektu. Jeśli zwrócony zostanie obiekt zerowy, można go użyć do utworzenia nowego obiektu. W obu przypadkach działa ten sam kod. Nie ma potrzeby oddzielnego kodu do kopiowania-modyfikacji i nowego. Jest to część filozofii, zgodnie z którą każda linia kodu jest innym miejscem na błędy; zmniejszenie linii zmniejsza błędy.
shawnhcorey
@shawnhcorey - co?
Telastyn
Obiekt zerowy powinien nadawać się do użytku tak, jakby to był prawdziwy obiekt. Na przykład rozważmy NaN IEEE (nie liczbę). Jeśli równanie pójdzie nie tak, jest zwracane. I można go wykorzystać do dalszych obliczeń, ponieważ każda operacja na nim zwróci NaN. Upraszcza kod, ponieważ nie musisz sprawdzać NaN po każdej operacji arytmetycznej.
shawnhcorey
6

Nie jestem dobrym źródłem obiektywnych informacji, ale subiektywnie:

  • Nie chcę, aby mój system umarł kiedykolwiek; dla mnie jest to znak źle zaprojektowanego lub niekompletnego systemu; kompletny system powinien obsłużyć wszystkie możliwe przypadki i nigdy nie przejść w nieoczekiwany stan; aby to osiągnąć, muszę być wyraźny w modelowaniu wszystkich przepływów i sytuacji; użycie wartości null nie jest w żaden sposób jawne i grupuje wiele różnych przypadków pod jedną umrella; Wolę obiekt NonExistingUser od null, wolę NaN od null, ... takie podejście pozwala mi być wyraźnym na temat tych sytuacji i ich obsługi w jak największej liczbie szczegółów, podczas gdy null pozostawia mi tylko opcję null; w takim środowisku jak Java każdy obiekt może być pusty, więc dlaczego wolisz ukrywać w tej ogólnej puli przypadków także swoją konkretną sprawę?
  • implementacje zerowe mają drastycznie inne zachowanie niż obiekt i są w większości przyklejone do zbioru wszystkich obiektów (dlaczego? dlaczego? dlaczego?); dla mnie taka drastyczna różnica wydaje się być wynikiem zaprojektowania wartości zerowych, które wskazują na błąd, w którym to przypadku system najprawdopodobniej umrze (jestem zaskoczony, że tak wielu ludzi woli umrzeć swoim systemem na powyższych postach), chyba że zostanie to wyraźnie potraktowane; dla mnie to błędne podejście w swoim katalogu głównym - pozwala domyślnie umrzeć systemowi, chyba że wyraźnie się tym zająłeś, to nie jest zbyt bezpieczne, prawda? ale w każdym przypadku niemożliwe jest napisanie kodu, który traktuje wartość null i obiekt w ten sam sposób - działa tylko kilka podstawowych wbudowanych operatorów (przypisanie, równość, parametry itd.), cały inny kod po prostu zawiedzie i potrzebujesz dwie zupełnie różne ścieżki;
  • lepsze wykorzystanie skal Null Object - możesz umieścić tyle informacji i struktury, ile chcesz; NonExistingUser jest bardzo dobrym przykładem - może zawierać wiadomość e-mail użytkownika, którego próbowałeś otrzymać, i sugerować utworzenie nowego użytkownika na podstawie tych informacji, natomiast w przypadku rozwiązania zerowego musiałbym pomyśleć, jak zachować wiadomość e-mail, do której ludzie próbowali uzyskać dostęp blisko obsługi wyniku zerowego;

W środowisku takim jak Java lub C # nie zapewni to głównej korzyści z jawności - bezpieczeństwo rozwiązania jest kompletne, ponieważ nadal możesz otrzymać wartość null w miejscu dowolnego obiektu w systemie, a Java nie ma żadnych środków (z wyjątkiem niestandardowych adnotacji dla Beans itp.), aby cię przed tym chronić, z wyjątkiem jawnych ifs. Ale w systemie wolnym od implementacji zerowej podejście do problemu w taki sposób (z obiektami reprezentującymi wyjątkowe przypadki) daje wszystkie wymienione korzyści. Więc spróbuj przeczytać o czymś innym niż języki głównego nurtu, a odkryjesz, że zmieniasz sposoby kodowania.

Ogólnie rzecz biorąc, obiekty zerowe / typy błędów / zwracane typy są uważane za bardzo solidną strategię obsługi błędów i dość matematycznie uzasadnioną, podczas gdy wyjątki dotyczące strategii Java lub C idą jak tylko jeden krok powyżej strategii „die ASAP” - więc w zasadzie zostawić cały ciężar niestabilność i nieprzewidywalność systemu dla dewelopera lub opiekuna.

Istnieją również monady, które można uznać za lepsze od tej strategii (początkowo także o wyższym stopniu złożoności zrozumienia), ale chodzi o przypadki, w których trzeba modelować zewnętrzne aspekty typu, podczas gdy Null Object modeluje aspekty wewnętrzne. Więc NonExistingUser jest prawdopodobnie lepiej modelowany jako monad może_istniejący.

I na koniec, nie sądzę, że istnieje związek między wzorcem Null Object a cichą obsługą błędów. Powinno być na odwrót - powinno zmusić cię do jawnego modelowania każdego przypadku błędu i odpowiednio go obsłużyć. Teraz oczywiście możesz zdecydować się być niechlujny (z jakiegokolwiek powodu) i pominąć jawne rozpatrywanie przypadku błędu, ale załatw go ogólnie - cóż, to był twój wyraźny wybór.

Bohdan Tsymbala
źródło
9
Powodem, dla którego lubię, że moja aplikacja kończy się niepowodzeniem, gdy dzieje się coś nieoczekiwanego, jest to, że stało się coś nieoczekiwanego. Jak to się stało? Czemu? Dostaję zrzut awaryjny i mogę zbadać i naprawić potencjalnie niebezpieczny problem, zamiast pozwolić, aby ukrył się w kodzie. W języku C # mogę oczywiście wychwycić wszystkie nieoczekiwane wyjątki, aby spróbować odzyskać aplikację, ale nawet wtedy chcę zarejestrować miejsce, w którym wystąpił problem i dowiedzieć się, czy jest to coś, co wymaga osobnej obsługi (nawet jeśli jest to „nie zaloguj ten błąd, faktycznie jest oczekiwany i możliwy do odzyskania ”).
Luaan
3

Myślę, że warto zauważyć, że żywotność obiektu zerowego wydaje się nieco inna w zależności od tego, czy Twój język jest wpisany statycznie. Ruby to język, który implementuje obiekt dla Null (lub Nilw terminologii tego języka).

Zamiast zwracać wskaźnik do niezainicjowanej pamięci, język zwróci obiekt Nil. Ułatwia to fakt, że język jest dynamicznie wpisywany. Funkcja nie gwarantuje, że zawsze zwróci an int. Może wrócić, Niljeśli tego właśnie potrzebuje. Jest to bardziej skomplikowane i trudne do zrobienia w języku o typie statycznym, ponieważ naturalnie oczekujesz, że wartość null będzie miała zastosowanie do każdego obiektu, który można wywołać przez odwołanie. Będziesz musiał rozpocząć wdrażanie zerowych wersji dowolnego obiektu, który może być zerowy (lub pójść drogą Scala / Haskell i mieć jakieś opakowanie „Może”).

MrLister poruszył fakt, że w C ++ nie ma gwarancji, że będziesz mieć inicjalizowaną referencję / wskaźnik podczas jego pierwszego tworzenia. Dysponując dedykowanym obiektem zerowym zamiast surowego wskaźnika zerowego, można uzyskać dodatkowe fajne funkcje. Ruby Nilzawiera funkcję to_s(toString), która daje pusty tekst. Jest to świetne, jeśli nie masz nic przeciwko otrzymaniu zerowego zwrotu na łańcuchu i chcesz pominąć zgłaszanie go bez awarii. Klasy otwarte pozwalają również ręcznie zastąpić dane wyjściowe w Nilrazie potrzeby.

To prawda, że ​​można to wszystko wziąć z odrobiną soli. Wierzę, że w języku dynamicznym obiekt zerowy może być wielką wygodą, jeśli jest używany poprawnie. Niestety pełne wykorzystanie jego możliwości może wymagać edycji podstawowej funkcji, co może utrudnić zrozumienie i utrzymanie programu przez osoby przyzwyczajone do pracy z bardziej standardowymi formularzami.

KChaloux
źródło
1

Aby uzyskać dodatkowy punkt widzenia, spójrz na Cel C, gdzie zamiast posiadania obiektu Null wartość zero działa jak obiekt. Każda wiadomość wysłana do obiektu zerowego nie ma żadnego efektu i zwraca wartość 0 / 0,0 / NO (false) / NULL / zero, niezależnie od typu zwracanej wartości metody. Jest to używane jako bardzo wszechobecny wzorzec w programowaniu MacOS X i iOS, i zwykle metody są zaprojektowane w taki sposób, że zerowy obiekt zwracający 0 jest rozsądnym wynikiem.

Na przykład, jeśli chcesz sprawdzić, czy łańcuch ma jeden lub więcej znaków, możesz napisać

aString.length > 0

i uzyskaj rozsądny wynik, jeśli aString == zero, ponieważ nieistniejący ciąg nie zawiera żadnych znaków. Ludzie zwykle przyzwyczajają się do tego, ale prawdopodobnie zajęłoby mi trochę czasu, aby przyzwyczaić się do sprawdzania wartości zerowych w innych językach.

(Jest też singletonowy obiekt klasy NSNull, który zachowuje się zupełnie inaczej i nie jest zbyt często wykorzystywany w praktyce).

gnasher729
źródło
Cel C sprawił, że obiekt Null Object / Null Pointer stał się naprawdę rozmazany. nildostać się #definedo NULL i zachowuje się jak obiekt zerowy. Jest to funkcja środowiska wykonawczego, podczas gdy w zerowych wskaźnikach języka C # / Java emitowane są błędy.
Maxthon Chan
0

Nie używaj, jeśli możesz tego uniknąć. Użyj funkcji fabrycznej zwracającej typ opcji, krotkę lub dedykowany typ sumy. Innymi słowy, reprezentują potencjalnie niewykonaną wartość z innym typem niż wartość gwarantowana, że ​​nie zostanie niewykonana.

Najpierw wypiszmy desiderata, a następnie zastanówmy się, jak możemy to wyrazić w kilku językach (C ++, OCaml, Python)

  1. przechwyć niechciane użycie domyślnego obiektu w czasie kompilacji.
  2. wyjaśnij, czy dana wartość jest potencjalnie niewykonana podczas czytania kodu.
  3. wybierz naszą rozsądną wartość domyślną, jeśli dotyczy, raz dla każdego rodzaju . Zdecydowanie nie wybieraj potencjalnie różnych ustawień domyślnych w każdej witrynie z połączeniami .
  4. ułatwią narzędziom do analizy statycznej lub ludziom grepszukanie potencjalnych błędów.
  5. W przypadku niektórych aplikacji program powinien kontynuować normalnie, jeśli nieoczekiwanie otrzyma wartość domyślną. W przypadku innych aplikacji program powinien natychmiast zatrzymać się, jeśli otrzyma wartość domyślną, najlepiej w sposób informacyjny.

Myślę, że napięcie pomiędzy zerowym wzorcem obiektu a zerowymi wskaźnikami pochodzi z (5). Jeśli wykryjemy błędy wystarczająco wcześnie, (5) stanie się dyskusyjne.

Rozważmy ten język według języka:

C ++

Moim zdaniem klasa C ++ powinna generalnie być domyślnie konstruowana, ponieważ ułatwia interakcje z bibliotekami i ułatwia korzystanie z tej klasy w kontenerach. Upraszcza także dziedziczenie, ponieważ nie trzeba myśleć o tym, który konstruktor nadklasy ma zostać wywołany.

Oznacza to jednak, że nie możesz mieć pewności, czy wartość typu MyClassjest w stanie domyślnym, czy nie. Oprócz wprowadzenia w bool nonemptypolu wykonawczym podobnego lub podobnego pola do domyślnej powierzchni, najlepszym, co możesz zrobić, jest tworzenie nowych instancji MyClassw sposób, który zmusza użytkownika do sprawdzenia .

Polecam przy użyciu funkcji fabryki, która zwraca std::optional<MyClass>, std::pair<bool, MyClass>lub odwołania R-wartość na std::unique_ptr<MyClass>ile to możliwe.

Jeśli chcesz, aby funkcja fabryczna zwróciła jakiś „symbol zastępczy”, który różni się od skonstruowanej domyślnie MyClass, użyj a std::pairi upewnij się, że dokumentuje to, że twoja funkcja to robi.

Jeśli funkcja fabryczna ma unikalną nazwę, łatwo jest grepją nazwać i poszukać niechlujstwa. Jednak trudno jest grepw przypadkach, w których programista powinien był użyć funkcji fabrycznej, ale nie zrobił tego .

OCaml

Jeśli używasz języka takiego jak OCaml, możesz po prostu użyć optiontypu (w standardowej bibliotece OCaml) lub eithertypu (nie w standardowej bibliotece, ale łatwo ją rozwinąć). Lub defaultabletyp (tworzę termin defaultable).

type 'a option =
    | None
    | Some of 'a

type ('a, 'b) either =
    | Left of 'a
    | Right of 'b

type 'a defaultable =
    | Default of 'a
    | Real of 'a

Jak defaultablepokazano powyżej, jest lepszy niż para, ponieważ użytkownik musi wyodrębnić dopasowanie wzorca, aby wyodrębnić 'ai nie może po prostu zignorować pierwszego elementu pary.

Ekwiwalent defaultabletypu pokazanego powyżej może być używany w C ++ przy użyciu a std::variantz dwoma instancjami tego samego typu, ale części std::variantinterfejsu API są bezużyteczne, jeśli oba typy są takie same. Jest to również dziwne zastosowanie, std::variantponieważ jego konstruktory typów są bezimienne.

Pyton

Zresztą nie dostajesz żadnego sprawdzania czasu kompilacji dla Pythona. Jednak dynamiczne pisanie nie powoduje, że do umieszczenia kompilatora potrzebna jest instancja zastępcza dowolnego typu.

Polecam po prostu zgłoszenie wyjątku, gdy będziesz zmuszony utworzyć domyślną instancję.

Jeśli nie jest to do zaakceptowania, zaleciłbym utworzenie DefaultedMyClassodziedziczonego MyClassi zwrócenie go z funkcji fabrycznej. Dzięki temu zyskujesz elastyczność w zakresie „eliminowania” funkcji w domyślnych instancjach, jeśli zajdzie taka potrzeba.

Gregory Nisbet
źródło