Wzorzec konstruktora: kiedy zawieść?

45

Wdrażając Wzorzec Konstruktora, często mylę się, kiedy pozwolić, aby budowanie się nie powiodło, a nawet co kilka dni potrafię zająć różne stanowisko w tej sprawie.

Najpierw jakieś wyjaśnienie:

  • Z wczesnym niepowodzeniem mam na myśli, że budowanie obiektu powinno zakończyć się niepowodzeniem, gdy tylko zostanie przekazany nieprawidłowy parametr. Więc wewnątrz SomeObjectBuilder.
  • Z porażką późną mam na myśli to, że budowanie obiektu może zakończyć się niepowodzeniem tylko w przypadku build()wywołania, które domyślnie wywołuje konstruktora obiektu do zbudowania.

Następnie kilka argumentów:

  • Za opóźnieniem: klasa konstruktora nie powinna być niczym więcej niż klasą, która po prostu przechowuje wartości. Co więcej, prowadzi to do mniejszego duplikowania kodu.
  • Na korzyść wczesnego niepowodzenia: Ogólne podejście w programowaniu oprogramowania polega na tym, że chcesz wykrywać problemy tak wcześnie, jak to możliwe, a zatem najbardziej logicznym miejscem do sprawdzenia byłoby w klasie konstruktora „konstruktor”, „ustawiacze” i ostatecznie w metodzie kompilacji.

Jaki jest w tym ogólny konsensus?

skiwi
źródło
8
Nie widzę żadnej korzyści z późnego upadku. To, co ktoś mówi, że klasa budownicza „powinna”, nie ma pierwszeństwa przed dobrym projektem, a wczesne wychwytywanie błędów jest zawsze lepsze niż późne łapanie błędów.
Doval
3
Innym sposobem na to jest to, że konstruktor może nie wiedzieć, jakie są poprawne dane. Niepowodzenie na początku w tym przypadku polega bardziej na niepowodzeniu, gdy tylko dowiesz się , że wystąpił błąd. Nie zawiedzie wcześnie, budowniczy zwróci nullobiekt, gdy wystąpi problem build().
Chris
Jeśli nie dodasz sposobu wydawania ostrzeżenia, a oferta oznacza sposób naprawy w kreatorze, nie ma sensu spóźniać się.
Mark

Odpowiedzi:

34

Spójrzmy na opcje, w których możemy umieścić kod weryfikacyjny:

  1. Wewnątrz seterów w Konstruktorze.
  2. Wewnątrz build()metody.
  3. Wewnątrz konstruowanej encji: zostanie ona wywołana build()metodą podczas tworzenia encji.

Opcja 1 pozwala nam wcześniej wykryć problemy, ale mogą wystąpić skomplikowane przypadki, w których możemy zweryfikować dane wejściowe tylko w pełnym kontekście, wykonując co najmniej część sprawdzania poprawności build()metodą. Zatem wybranie opcji 1 doprowadzi do niespójnego kodu z częścią sprawdzania poprawności w jednym miejscu, a drugą częścią w innym miejscu.

Opcja 2 nie jest znacznie gorsza niż opcja 1, ponieważ zwykle setery w programie budującym są wywoływane tuż przed build(), szczególnie w płynnych interfejsach. Dlatego w większości przypadków nadal można wykryć problem wystarczająco wcześnie. Jeśli jednak konstruktor nie jest jedynym sposobem na utworzenie obiektu, doprowadzi to do duplikacji kodu sprawdzania poprawności, ponieważ będziesz musiał mieć go wszędzie tam, gdzie tworzysz obiekt. Najbardziej logicznym rozwiązaniem w tym przypadku będzie umieszczenie walidacji tak blisko stworzonego obiektu, jak to możliwe, czyli wewnątrz niego. I to jest opcja 3 .

Z punktu widzenia SOLID, sprawdzanie poprawności w kreatorze również narusza SRP: klasa konstruktora ma już obowiązek agregowania danych w celu skonstruowania obiektu. Sprawdzanie poprawności polega na ustanawianiu umów dotyczących własnego stanu wewnętrznego, nową odpowiedzialnością jest sprawdzanie stanu innego obiektu.

Tak więc, z mojego punktu widzenia, nie tylko lepiej jest zawieść późno z perspektywy projektowania, ale także lepiej zawieść wewnątrz zbudowanej jednostki, niż w samym budowniczym.

UPD: ten komentarz przypomniał mi o jeszcze jednej możliwości, gdy sprawdzanie poprawności wewnątrz konstruktora (opcja 1 lub 2) ma sens. Ma to sens, jeśli budowniczy ma własne kontrakty na obiekty, które tworzy. Załóżmy na przykład, że mamy konstruktora, który konstruuje ciąg o określonej treści, powiedzmy, listę zakresów liczb 1-2,3-4,5-6. Ten konstruktor może mieć taką metodę addRange(int min, int max). Wynikowy ciąg nie wie nic o tych liczbach, ani nie powinien wiedzieć. Sam konstruktor definiuje format ciągu i ograniczenia liczb. Dlatego metoda addRange(int,int)musi sprawdzić poprawność liczb wejściowych i zgłosić wyjątek, jeśli max jest mniejsze niż min.

To powiedziawszy, ogólną zasadą będzie sprawdzanie poprawności tylko umów określonych przez samego konstruktora.

Ivan Gammel
źródło
Myślę, że warto zauważyć, że chociaż Opcja 1 może prowadzić do „niespójnych” czasów sprawdzania, to nadal można ją postrzegać jako spójną, jeśli wszystko jest „jak najwcześniej”. Trochę łatwiej jest uczynić „tak wcześnie, jak to możliwe” bardziej konkretnym, jeśli zamiast tego zostanie użyty wariant konstruktora, StepBuilder.
Joshua Taylor
Jeśli konstruktor identyfikatora URI zgłasza wyjątek, jeśli zostanie przekazany ciąg zerowy, czy jest to naruszenie SOLID? Śmieci
Gusdor
@Gusdor tak, jeśli sam zgłasza wyjątek. Jednak z punktu widzenia użytkownika wszystkie opcje wyglądają jak wyjątek generowany przez konstruktora.
Ivan Gammel
Dlaczego więc nie mieć sprawdzania poprawności () wywoływanego przez build ()? W ten sposób kopiowanie, spójność i brak naruszenia SRP są niewielkie. Umożliwia także sprawdzanie poprawności danych bez próby kompilacji, a sprawdzanie poprawności jest bliskie tworzeniu.
StellarVortex
@StellarVortex w tym przypadku zostanie sprawdzony dwukrotnie - raz w builder.build (), a jeśli dane są prawidłowe i przejdziemy do konstruktora obiektu, w tym konstruktorze.
Ivan Gammel
34

Biorąc pod uwagę, że używasz Javy, rozważ autorytatywne i szczegółowe wskazówki dostarczone przez Joshua Blocha w artykule Tworzenie i niszczenie obiektów Java (pogrubiona czcionka w poniższym cytacie jest moja):

Podobnie jak konstruktor, konstruktor może nakładać niezmienniki na swoje parametry. Metoda kompilacji może sprawdzić te niezmienniki. Bardzo ważne jest, aby były one sprawdzane po skopiowaniu parametrów z konstruktora do obiektu i aby były sprawdzane na polach obiektu, a nie na polach konstruktora (pozycja 39). Jeśli jakiekolwiek niezmienniki zostaną naruszone, metoda kompilacji powinna rzucić IllegalStateException(pozycja 60). Szczegółowa metoda wyjątku powinna wskazywać, który niezmiennik jest naruszony (pozycja 63).

Innym sposobem narzucenia niezmienników obejmujących wiele parametrów jest to, aby metody ustawiające pobierały całe grupy parametrów, które musi utrzymywać jakiś niezmiennik. Jeśli niezmiennik nie jest spełniony, metoda ustawiająca generuje an IllegalArgumentException. Ma to tę zaletę, że wykrywa niezmienną awarię, gdy tylko zostaną przekazane nieprawidłowe parametry, zamiast czekać na wywołanie kompilacji.

Uwaga: zgodnie z objaśnieniem redaktora tego artykułu, „elementy” w powyższym cytacie odnoszą się do reguł przedstawionych w Effective Java, wydanie drugie .

W artykule nie zagłębiono się w wyjaśnienie, dlaczego jest to zalecane, ale jeśli o tym pomyślisz, przyczyny są dość oczywiste. Ogólna wskazówka na temat zrozumienia tego znajduje się w tym artykule, w wyjaśnieniu, w jaki sposób koncepcja konstruktora jest połączona z koncepcją konstruktora - i oczekuje się, że niezmienniki klas będą sprawdzane w konstruktorze, a nie w żadnym innym kodzie, który może poprzedzać / przygotowywać jego wywołanie.

Aby bardziej konkretnie zrozumieć, dlaczego sprawdzanie niezmienników przed wywołaniem kompilacji byłoby błędne, rozważ popularny przykład CarBuilder . Metody konstruktora można wywoływać w dowolnej kolejności, w wyniku czego do momentu kompilacji nie można naprawdę wiedzieć, czy dany parametr jest prawidłowy.

Weź pod uwagę, że samochód sportowy nie może mieć więcej niż 2 miejsca. Skąd można wiedzieć, czy setSeats(4)wszystko jest w porządku? Dopiero w kompilacji można się z pewnością dowiedzieć, czy setSportsCar()został wywołany, czy nie, co oznacza, czy rzucić, TooManySeatsExceptionczy nie.

komar
źródło
3
+1 za zalecenie, jakie typy wyjątków do rzucenia, dokładnie to, czego szukałem.
Xantix
Nie jestem pewien, czy dostanę alternatywę. Wydaje się, że mówi się wyłącznie o tym, kiedy niezmienniki można zweryfikować tylko w grupach. Konstruktor akceptuje pojedyncze atrybuty, gdy nie obejmują one żadnych innych, i akceptuje grupy atrybutów tylko wtedy, gdy grupa ma niezmiennik sam w sobie. W takim przypadku, czy pojedynczy atrybut powinien generować wyjątek przed kompilacją?
Didier A.
19

Nieprawidłowe wartości, które są nieprawidłowe, ponieważ nie są tolerowane, powinny moim zdaniem zostać natychmiast ujawnione. Innymi słowy, jeśli akceptujesz tylko liczby dodatnie i przekazywana jest liczba ujemna, nie trzeba czekać na build()wywołanie. Nie wziąłbym pod uwagę tego rodzaju problemów, których „można się spodziewać”, ponieważ jest to warunek wstępny wywołania metody na początek. Innymi słowy, prawdopodobnie nie będziesz zależał od niepowodzenia ustawienia niektórych parametrów. Bardziej prawdopodobne jest, że założysz, że parametry są poprawne lub sam wykonasz kontrolę.

Jednak w przypadku bardziej skomplikowanych problemów, które nie są tak łatwo sprawdzane, lepiej poinformować o tym podczas rozmowy telefonicznej build(). Dobrym przykładem może być podanie podanych informacji o połączeniu w celu nawiązania połączenia z bazą danych. W takim przypadku, chociaż technicznie można sprawdzić takie warunki, nie jest już intuicyjny i tylko komplikuje kod. Moim zdaniem są to również rodzaje problemów, które mogą się zdarzyć i których nie można przewidzieć, dopóki nie spróbujesz. Jest to swego rodzaju różnica między dopasowaniem łańcucha do wyrażenia regularnego, aby sprawdzić, czy można go przeanalizować jako int, a po prostu próbą jego przetworzenia, obsługując wszelkie potencjalne wyjątki, które mogą wystąpić w konsekwencji.

Ogólnie nie lubię zgłaszać wyjątków podczas ustawiania parametrów, ponieważ oznacza to konieczność wychwycenia zgłoszonego wyjątku, więc zazwyczaj preferuję sprawdzanie poprawności w build(). Z tego powodu wolę używać wyjątku RuntimeException, ponieważ ponownie błędy w przekazywanych parametrach zwykle nie powinny się zdarzyć.

Jest to jednak najlepsza praktyka niż cokolwiek innego. Mam nadzieję, że to odpowiada na twoje pytanie.

Neil
źródło
11

O ile mi wiadomo, ogólną praktyką (nie jestem pewien, czy istnieje konsensus) jest nieudana, gdy tylko możliwe jest wykrycie błędu. Utrudnia to również niezamierzone niewłaściwe użycie interfejsu API.

Jeśli jest to trywialny atrybut, który można sprawdzić na wejściu, taki jak pojemność lub długość, która powinna być nieujemna, najlepiej od razu zawieść. Zatrzymanie błędu zwiększa odległość między błędem a sprzężeniem zwrotnym, co utrudnia znalezienie źródła problemu.

Jeśli masz nieszczęście być w sytuacji, gdy ważność atrybutu zależy od innych, masz dwie możliwości:

  • Wymagaj, aby oba (lub więcej) atrybutów były podawane jednocześnie (tj. Wywołanie pojedynczej metody).
  • Sprawdź ważność, gdy tylko dowiesz się, że nie nadchodzą już żadne zmiany: kiedy build()tak się nazywa.

Jak w przypadku większości rzeczy, jest to decyzja podejmowana w kontekście. Jeśli kontekst powoduje, że wczesne niepowodzenia jest niewygodne lub skomplikowane, można dokonać kompromisu, aby odłożyć kontrole na później, ale domyślnie powinno być szybkie przełączanie.

JvR
źródło
Podsumowując, mówisz, że rozsądnie jest jak najwcześniej zweryfikować wszystko, co mogło być objęte typem obiektowym / pierwotnym? Jak unsigned, @NonNullitp.
skiwi
2
@skiwi W zasadzie tak. Kontrole domen, kontrole zerowe, tego rodzaju rzeczy. Nie zalecałbym umieszczania w tym dużo więcej: budowniczowie to na ogół proste rzeczy.
JvR
1
Warto zauważyć, że jeśli ważność jednego parametru zależy od wartości innego, można legalnie odrzucić wartość parametru tylko wtedy, gdy wiadomo, że drugi parametr jest „naprawdę” ustalony . Jeśli dopuszczalne jest wielokrotne ustawianie wartości parametru [przy ostatnim ustawieniu, które ma pierwszeństwo], to w niektórych przypadkach najbardziej naturalnym sposobem ustawienia obiektu może być ustawienie parametru Xna wartość, która jest nieprawidłowa, biorąc pod uwagę obecną wartość Y, ale przed wywołaniem build()ustaw Ywartość, która sprawi, że będzie Xpoprawna.
supercat
Jeśli np. Budujesz a, Shapea konstruktor ma WithLefti ma WithRightwłaściwości, a chcesz dostosować budowniczego do budowy obiektu w innym miejscu, wymaganie, aby WithRightwywołać go najpierw podczas przesuwania obiektu w prawo, a WithLeftpodczas przesuwania go w lewo, spowodowałoby niepotrzebną złożoność w porównaniu z WithLeftustawieniem lewej krawędzi po prawej stronie starej prawej krawędzi, pod warunkiem, że WithRightnaprawia prawą krawędź przed buildwywołaniem.
supercat
0

Podstawową zasadą jest „wczesne niepowodzenie”.

Nieco bardziej zaawansowaną zasadą jest „zawodzić jak najwcześniej”.

Jeśli właściwość jest z natury nieważna ...

CarBuilder.numberOfWheels( -1 ). ...  

... wtedy natychmiast go odrzucasz.

Inne przypadki mogą wymagać kombinacji wartości i mogą być lepiej umieszczone w metodzie build ():

CarBuilder.numberOfWheels( 0 ).type( 'Hovercraft' ). ...  
Phill W.
źródło