Dlaczego klasa powinna być czymś innym niż „abstrakcyjnym” lub „ostatecznym / zapieczętowanym”?

29

Po ponad 10 latach programowania w języku Java / C # zaczynam tworzyć:

  • klasy abstrakcyjne : umowa nie ma być tworzona jako taka, jaka jest.
  • klasy końcowe / zapieczętowane : implementacja nie ma służyć jako klasa bazowa dla czegoś innego.

Nie mogę wymyślić żadnej sytuacji, w której prosta „klasa” (tj. Ani abstrakcyjna, ani ostateczna / zapieczętowana) byłaby „mądrym programowaniem”.

Dlaczego klasa powinna być czymś innym niż „abstrakcyjnym” lub „ostatecznym / zapieczętowanym”?

EDYTOWAĆ

Ten świetny artykuł wyjaśnia moje obawy znacznie lepiej niż potrafię.

Nicolas Repiquet
źródło
21
Ponieważ nazywa się to Open/Closed principle, a nieClosed Principle.
StuperUser
2
Jakiego rodzaju rzeczy piszesz profesjonalnie? Może to bardzo dobrze wpłynąć na twoją opinię w tej sprawie.
Cóż, znam niektórych twórców platform, którzy uszczelniają wszystko, ponieważ chcą zminimalizować interfejs dziedziczenia.
K ..
4
@StuperUser: To nie jest powód, to banał. OP nie pyta o frazes, pyta dlaczego . Jeśli nie ma żadnego uzasadnienia dla zasady, nie ma powodu, aby zwracać na nią uwagę.
Michael Shaw
1
Zastanawiam się, ile struktur interfejsu użytkownika zepsułoby się, zmieniając klasę Window na zapieczętowaną.
Reactgular,

Odpowiedzi:

46

Jak na ironię, uważam, że jest odwrotnie: użycie klas abstrakcyjnych jest raczej wyjątkiem niż regułą i mam skłonność do marszczenia brwi na końcowych / zapieczętowanych klasach.

Interfejsy są bardziej typowym mechanizmem według umowy, ponieważ nie określasz żadnych elementów wewnętrznych - nie martwisz się o nie. Pozwala to na niezależność każdej realizacji tego kontraktu. Jest to kluczowe w wielu domenach. Na przykład, jeśli budujesz ORM, bardzo ważne jest, abyś mógł przekazać zapytanie do bazy danych w jednolity sposób, ale implementacje mogą być zupełnie inne. Jeśli użyjesz do tego celu klas abstrakcyjnych, ostatecznie okablujesz elementy, które mogą, ale nie muszą mieć zastosowania do wszystkich implementacji.

W przypadku klas końcowych / zapieczętowanych jedyną wymówką, jaką kiedykolwiek widzę, by z nich korzystać, jest to, że zezwolenie na zastąpienie jest w rzeczywistości niebezpieczne - być może algorytm szyfrowania lub coś takiego. Poza tym nigdy nie wiesz, kiedy możesz chcieć rozszerzyć funkcjonalność z przyczyn lokalnych. Pieczętowanie klasy ogranicza twoje opcje dla zysków, które nie istnieją w większości scenariuszy. O wiele bardziej elastyczne jest pisanie klas w taki sposób, aby można je było później rozszerzyć.

Ten ostatni pogląd został utrwalony przez pracę z komponentami innych firm, które uszczelniały klasy, uniemożliwiając w ten sposób integrację, która znacznie ułatwiłaby życie.

Michał
źródło
17
+1 za ostatni akapit. Często stwierdziłem, że zbyt duża enkapsulacja w bibliotekach stron trzecich (lub nawet w standardowych klasach bibliotek!) Jest większą przyczyną bólu niż zbyt mała enkapsulacja.
Mason Wheeler
5
Zwykłym argumentem za zapieczętowaniem klas jest to, że nie można przewidzieć wielu różnych sposobów, w jakie klient może zastąpić twoją klasę, a zatem nie możesz dać żadnych gwarancji dotyczących jej zachowania. Zobacz blogs.msdn.com/b/ericlippert/archive/2004/01/22/…
Robert Harvey
12
@RobertHarvey Znam argument i brzmi świetnie w teorii. Ty jako projektant obiektu nie można przewidzieć, w jaki sposób ludzie mogą rozszerzyć swoje klasy - właśnie dlatego powinny one nie być uszczelnione. Nie możesz obsłużyć wszystkiego - w porządku. Nie rób Ale nie zabieraj też opcji.
Michael
6
@RobertHarvey to nie tylko ludzie łamiący LSP dostający to, na co zasłużyli?
StuperUser
2
Nie jestem też wielkim fanem zamkniętych klas, ale mogłem zrozumieć, dlaczego niektóre firmy takie jak Microsoft (które często muszą wspierać rzeczy, których się nie popsuły) uznają je za atrakcyjne. Użytkownicy frameworka niekoniecznie powinni mieć wiedzę na temat wewnętrznych elementów klasy.
Robert Harvey
11

Że jest to świetny artykuł Eric Lippert, ale nie sądzę, że obsługuje swój punkt widzenia.

Twierdzi, że wszystkie klasy wystawione do użytku przez innych powinny być zapieczętowane lub w inny sposób niemożliwe do rozszerzenia.

Twoim założeniem jest, że wszystkie klasy powinny być abstrakcyjne lub zapieczętowane.

Duża różnica.

Artykuł EL nic nie mówi o (prawdopodobnie wielu) klasach produkowanych przez jego zespół, o których ty i ja nic nie wiemy. Ogólnie rzecz biorąc, publicznie wyeksponowane klasy w ramach są tylko podzbiorem wszystkich klas zaangażowanych we wdrażanie tych ram.

Jaskółka oknówka
źródło
Ale możesz zastosować ten sam argument do klas, które nie są częścią interfejsu publicznego. Jednym z głównych powodów, aby uczynić klasę końcową, jest to, że działa jako środek bezpieczeństwa, aby zapobiec niechlujnemu kodowi. Ten argument jest tak samo ważny dla dowolnej bazy kodu, która jest udostępniana przez różnych programistów w czasie, a nawet jeśli jesteś jedynym programistą. Po prostu chroni semantykę kodu.
DPM
Myślę, że idea, że ​​klasy powinny być abstrakcyjne lub zapieczętowane, jest częścią szerszej zasady, która polega na tym, że należy unikać używania zmiennych możliwych do utworzenia typów klas. Takie uniknięcie pozwoli stworzyć typ, który może być używany przez konsumentów danego rodzaju, bez konieczności dziedziczenia wszystkich jego prywatnych członków. Niestety, takie projekty tak naprawdę nie działają ze składnią konstruktora publicznego. Zamiast tego kod musiałby zastąpić new List<Foo>()czymś takim List<Foo>.CreateMutable().
supercat
5

Z perspektywy Java uważam, że końcowe klasy nie są tak mądre, jak się wydaje.

Wiele narzędzi (zwłaszcza AOP, JPA itp.) Działa z opóźnieniem czasu ładowania, więc muszą rozszerzyć twoje klauzule. Innym sposobem byłoby utworzenie delegatów (nie tych .NET) i delegowanie wszystkiego do oryginalnej klasy, co byłoby znacznie bardziej nieporządne niż rozszerzenie klas użytkowników.

Uwe Plonus
źródło
5

Dwa typowe przypadki, w których będziesz potrzebować waniliowych, niezapieczętowanych klas:

  1. Techniczne: jeśli masz hierarchię głęboką na więcej niż dwa poziomy i chcesz mieć możliwość tworzenia czegoś pośrodku.

  2. Zasada: Czasami pożądane jest pisanie klas, które są jawne, zaprojektowane w taki sposób, aby były bezpiecznie rozszerzane . (Dzieje się tak często, gdy piszesz API takie jak Eric Lippert lub pracujesz w zespole przy dużym projekcie). Czasami chcesz napisać klasę, która działa sama, ale jest zaprojektowana z myślą o rozszerzalności.

Eric Lippert myśli dotyczące marek uszczelniających sens, ale przyznaje też, że oni zrobić projekt na rozciągliwość pozostawiając klasie „open”.

Tak, wiele klas jest zaplombowanych w BCL, ale ogromna liczba klas nie jest i można je rozszerzać na wiele wspaniałych sposobów. Jednym z przykładów, który przychodzi na myśl, są formularze Windows Forms, w których można dodawać dane lub zachowania do prawie każdego Controlpoprzez dziedziczenie. Oczywiście można to zrobić na inne sposoby (wzór dekoratora, różne rodzaje kompozycji itp.), Ale dziedziczenie również działa bardzo dobrze.

Dwie uwagi dotyczące .NET:

  1. W większości przypadków klasy uszczelnienia często nie mają decydującego znaczenia dla bezpieczeństwa, ponieważ spadkobiercy nie mogą zadzierać z twoją virtualniefunkcjonalnością, z wyjątkiem jawnych implementacji interfejsu.
  2. Czasami odpowiednią alternatywą jest wykonanie Konstruktora internalzamiast uszczelnienia klasy, co pozwala na odziedziczenie go w bazie kodu, ale nie poza nim.
Kevin McCormick
źródło
4

Uważam, że prawdziwym powodem, dla którego wielu ludzi uważa, że ​​klasy powinny być final/ sealedjest to, że większość nieabstrakcyjnych rozszerzalnych klas nie jest odpowiednio dokumentowana .

Pozwól mi rozwinąć. Zaczynając z daleka, niektórzy programiści uważają, że dziedziczenie jako narzędzie w OOP jest powszechnie nadużywane i nadużywane. Wszyscy przeczytaliśmy zasadę substytucji Liskowa, choć nie powstrzymało nas to przed naruszeniem jej setki (może nawet tysiące) razy.

Chodzi o to, że programiści uwielbiają ponownie wykorzystywać kod. Nawet jeśli nie jest to dobry pomysł. Dziedziczenie jest kluczowym narzędziem w tym „ponownym wykorzystywaniu” kodu. Powrót do ostatniego / zamkniętego pytania.

Właściwa dokumentacja dla klasy końcowej / zapieczętowanej jest stosunkowo niewielka: opisujesz, co robi każda metoda, jakie są argumenty, zwracana wartość itp. Wszystkie zwykłe rzeczy.

Jednak, gdy odpowiednio dokumentujesz rozszerzalną klasę , musisz uwzględnić przynajmniej następujące elementy :

  • Zależności między metodami (która metoda wywołuje które itp.)
  • Zależności od zmiennych lokalnych
  • Umowy wewnętrzne, które klasa rozszerzająca powinna uszanować
  • Konwencja wywoływania dla każdej metody (np. Gdy ją przesłonisz, wywołujesz superimplementację? Czy wywołujesz ją na początku metody, czy na końcu? Pomyśl konstruktor vs destruktor)
  • ...

Są tuż przy mojej głowie. Mogę ci podać przykład, dlaczego każdy z nich jest ważny, a pominięcie go zepsuje rozszerzającą się klasę.

Teraz zastanów się, ile wysiłku w dokumentację należy poświęcić na odpowiednie udokumentowanie każdej z tych rzeczy. Uważam, że klasa z 7-8 metodami (która może być za dużo w wyidealizowanym świecie, ale w rzeczywistości jest za mało) równie dobrze mogłaby mieć dokumentację o 5 stronach, tylko tekstową. Zamiast tego wychylamy się do połowy i nie zamykamy klasy, aby inni mogli z niej korzystać, ale też nie dokumentowali jej odpowiednio, ponieważ zajmie to ogromną ilość czasu (i wiesz, że może i tak nigdy nie będzie przedłużany, więc po co zawracać sobie głowę?).

Jeśli projektujesz klasę, możesz odczuwać pokusę, aby ją zapieczętować, aby ludzie nie mogli jej używać w sposób, którego nie przewidziałeś (i nie jesteś przygotowany). Z drugiej strony, kiedy używasz cudzego kodu, czasem z publicznego API nie ma widocznego powodu, aby klasa była ostateczna, i możesz pomyśleć „Cholera, to tylko kosztowało mnie 30 minut w poszukiwaniu obejścia”.

Myślę, że niektóre elementy rozwiązania to:

  • Najpierw upewnij się, że rozszerzenie jest dobrym pomysłem, gdy jesteś klientem kodu, i naprawdę preferuj kompozycję zamiast dziedziczenia.
  • Po drugie, przeczytaj instrukcję w całości (ponownie jako klient), aby upewnić się, że nie przeoczysz czegoś, o czym wspomniano.
  • Po trzecie, kiedy piszesz fragment kodu, którego użyje klient, napisz odpowiednią dokumentację dla kodu (tak, na długą drogę). Jako pozytywny przykład mogę podać dokumenty Apple na iOS. Oni nie są wystarczające, aby użytkownik zawsze właściwie rozszerzyć swoje klasy, ale przynajmniej to trochę informacji na temat dziedziczenia. Co więcej niż mogę powiedzieć dla większości interfejsów API.
  • Po czwarte, spróbuj rozszerzyć swoją klasę, aby upewnić się, że działa. Jestem wielkim zwolennikiem włączenia wielu próbek i testów do interfejsów API, a kiedy wykonujesz test, możesz równie dobrze przetestować łańcuchy dziedziczenia: w końcu są one częścią twojej umowy!
  • Po piąte, w sytuacjach, w których masz wątpliwości, wskaż, że klasa nie powinna być przedłużana i że zrobienie tego jest złym pomysłem (tm). Wskazuj, że nie powinieneś ponosić odpowiedzialności za takie niezamierzone użycie, ale nadal nie zamykaj klasy. Oczywiście nie obejmuje to przypadków, w których klasa powinna być w 100% zapieczętowana.
  • Wreszcie, zamykając klasę, zapewnij interfejs jako pośredni haczyk, aby klient mógł „przepisać” własną zmodyfikowaną wersję klasy i obejść twoją „zapieczętowaną” klasę. W ten sposób może zastąpić zapieczętowaną klasę swoją implementacją. To powinno być oczywiste, ponieważ jest to luźne sprzęganie w najprostszej formie, ale nadal warto o tym wspomnieć.

Warto również wspomnieć o następującym „filozoficznym” pytaniu: czy klasa jest sealed/ jest finalczęścią kontraktu dla klasy, czy szczegół implementacyjny? Teraz nie chcę tam iść, ale odpowiedź na to pytanie powinna również wpłynąć na twoją decyzję, czy zapieczętować klasę, czy nie.

K.Steff
źródło
3

Klasa nie powinna być ostateczna / zapieczętowana ani abstrakcyjna, jeżeli:

  • Jest to użyteczne samo w sobie, tzn. Korzystne jest posiadanie instancji tej klasy.
  • Korzystne jest, aby ta klasa była podklasą / klasą bazową innych klas.

Weźmy na przykład ObservableCollection<T>klasę w języku C # . Musi jedynie dodać wywoływanie zdarzeń do normalnych operacji a Collection<T>, dlatego podklasuje Collection<T>. Collection<T>sama w sobie jest opłacalną klasą i tak też jest ObservableCollection<T>.

FishBasketGordo
źródło
Gdybym był za to odpowiedzialny, prawdopodobnie wykonam CollectionBase(streszczenie), Collection : CollectionBase(zapieczętowane), ObservableCollection : CollectionBase(zapieczętowane). Jeśli przyjrzysz się uważnie kolekcji <T> , zobaczysz, że jest to klasa abstrakcyjna w połowie oceniona.
Nicolas Repiquet
2
Jaką korzyść zyskujesz mając trzy klasy zamiast tylko dwóch? A w jaki Collection<T>sposób „na wpół oceniana klasa abstrakcyjna”?
FishBasketGordo
Collection<T>odsłania wiele elementów wewnętrznych dzięki chronionym metodom i właściwościom, i wyraźnie ma być klasą podstawową dla specjalistycznej kolekcji. Ale nie jest jasne, gdzie należy umieścić kod, ponieważ nie ma żadnych abstrakcyjnych metod do implementacji. ObservableCollectiondziedziczy po Collectioni nie jest zapieczętowany, więc możesz ponownie odziedziczyć po nim. I możesz uzyskać dostęp do Itemschronionej nieruchomości, co pozwala dodawać elementy do kolekcji bez podnoszenia zdarzeń ... Fajnie.
Nicolas Repiquet
@NicolasRepiquet: W wielu językach i ramach normalne idiomatyczne sposoby tworzenia obiektu wymagają, aby typ zmiennej, która będzie przechowywać obiekt, był taki sam, jak typ tworzonej instancji. W wielu przypadkach idealnym zastosowaniem byłoby przekazywanie odniesień do typu abstrakcyjnego, ale zmusiłoby to wiele kodu do używania jednego typu dla zmiennych i parametrów oraz innego konkretnego typu podczas wywoływania konstruktorów. Prawie niemożliwe, ale trochę niezręczne.
supercat
3

Problem z końcowymi / zapieczętowanymi klasami polega na tym, że próbują rozwiązać problem, który jeszcze się nie zdarzył. Jest to przydatne tylko wtedy, gdy problem istnieje, ale jest frustrujący, ponieważ firma zewnętrzna nałożyła ograniczenia. Rzadko klasa uszczelnienia rozwiązuje bieżący problem, co utrudnia argumentowanie jego przydatności.

Są przypadki, w których klasa powinna być zapieczętowana. Jako przykład; Klasa zarządza przydzielonymi zasobami / pamięcią w taki sposób, że nie jest w stanie przewidzieć, w jaki sposób przyszłe zmiany mogą zmienić to zarządzanie.

Przez lata odkryłem, że enkapsulacja, wywołania zwrotne i zdarzenia są o wiele bardziej elastyczne / przydatne niż abstrakcyjne klasy. Widzę za dużo kodu z dużą hierarchią klas, w których enkapsulacja i zdarzenia uprościłyby życie programistom.

Reactgular
źródło
1
Ostatecznie zamknięte oprogramowanie rozwiązuje problem: „Czuję potrzebę narzucenia mojej woli przyszłym opiekunom tego kodu”.
Kaz
To nie było coś ostatecznego / pieczęć dodanego do OOP, ponieważ nie pamiętam, żeby był w pobliżu, kiedy byłem młodszy. Wygląda na to, że jest to funkcja po przemyśleniu.
Reactgular
Finalizowanie istniało jako procedura łańcucha narzędzi w systemach OOP, zanim OOP został ogłuszony przez języki takie jak C ++ i Java. Programiści pracują w Smalltalk, Lisp z maksymalną elastycznością: wszystko można rozszerzyć, cały czas dodawać nowe metody. Następnie skompilowany obraz systemu podlega optymalizacji: przyjmuje się założenie, że system nie zostanie rozszerzony przez użytkowników końcowych, a zatem oznacza to, że wysyłanie metod można zoptymalizować na podstawie podsumowania metod i metod klasy istnieją teraz .
Kaz
Nie sądzę, że to to samo, ponieważ jest to tylko funkcja optymalizacji. Nie pamiętam, by istniało uszczelnienie we wszystkich wersjach Javy, ale mogę się mylić, ponieważ nie używam go zbyt często.
Reactgular
To, że manifestuje się jako niektóre deklaracje, które należy umieścić w kodzie, nie oznacza, że ​​to nie to samo.
Kaz
2

„Uszczelnianie” lub „finalizowanie” w systemach obiektowych pozwala na pewne optymalizacje, ponieważ znany jest pełny wykres wysyłki.

Oznacza to, że utrudniamy zmianę systemu na coś innego, co jest kompromisem w zakresie wydajności. (To jest istota większości optymalizacji.)

Pod wszystkimi innymi względami jest to strata. Systemy powinny być domyślnie otwarte i rozszerzalne. Powinno być łatwe dodawanie nowych metod do wszystkich klas i dowolne rozszerzanie.

Nie zyskujemy dziś żadnej nowej funkcji, podejmując kroki zapobiegające przyszłemu rozszerzeniu.

Jeśli więc robimy to w celu samej prewencji, to staramy się kontrolować życie przyszłych opiekunów. Chodzi o ego. „Nawet, gdy już tu nie będę pracować, ten kod zostanie zachowany, do cholery!”

Kaz
źródło
1

Wydaje się, że przychodzą na myśl klasy testowe. Są to klasy wywoływane w sposób zautomatyzowany lub „do woli” na podstawie tego, co programista / tester próbuje osiągnąć. Nie jestem pewien, czy kiedykolwiek widziałem lub nie słyszałem o skończonej klasie testów prywatnych.

joshin4colours
źródło
O czym ty mówisz W jaki sposób klasy testowe nie mogą być ostateczne / zapieczętowane / abstrakcyjne?
1

Czas, w którym musisz rozważyć zajęcia, które mają zostać przedłużone, to czas, kiedy planujesz prawdziwe plany na przyszłość. Pozwól, że dam ci prawdziwy przykład z mojej pracy.

Sporo czasu spędzam na pisaniu narzędzi interfejsu między naszym głównym produktem a systemami zewnętrznymi. Gdy dokonujemy nowej sprzedaży, jednym dużym komponentem jest zestaw eksporterów, które są zaprojektowane do uruchamiania w regularnych odstępach czasu, które generują pliki danych szczegółowo opisujące zdarzenia, które miały miejsce tego dnia. Te pliki danych są następnie zużywane przez system klienta.

To doskonała okazja do przedłużenia zajęć.

Mam klasę eksportu, która jest klasą podstawową każdego eksportera. Wie, jak połączyć się z bazą danych, dowiedzieć się, gdzie musiała ostatnio działać, i utworzyć archiwa generowanych plików danych. Zapewnia również zarządzanie plikami właściwości, rejestrowanie i kilka prostych procedur obsługi wyjątków.

Ponadto mam innego eksportera do pracy z każdym rodzajem danych, być może istnieje aktywność użytkownika, dane transakcyjne, dane zarządzania gotówką itp.

Na szczycie tego stosu umieszczam warstwę specyficzną dla klienta, która implementuje strukturę pliku danych, której potrzebuje klient.

W ten sposób podstawowy eksporter bardzo rzadko się zmienia. Eksporterzy podstawowych typów danych czasami się zmieniają, ale rzadko, i zwykle tylko w celu obsługi zmian schematu bazy danych, które i tak powinny zostać rozpowszechnione wśród wszystkich klientów. Jedyną pracą, jaką muszę wykonać dla każdego klienta, jest ta część kodu, która jest specyficzna dla tego klienta. Idealny świat!

Struktura wygląda więc tak:

Base
 Function1
  Customer1
  Customer2
 Function2
 ...

Chodzi przede wszystkim o to, że dzięki takiej architekturze kodu mogę wykorzystać dziedziczenie przede wszystkim do ponownego użycia kodu.

Muszę powiedzieć, że nie mogę wymyślić żadnego powodu, by przejść trzy warstwy.

Wielokrotnie używałem dwóch warstw, na przykład, aby mieć wspólną Tableklasę, która implementuje zapytania do tabel bazy danych, podczas gdy podklasy Tableimplementują określone szczegóły każdej tabeli, używając enumdo zdefiniowania pól. Pozwalając enumimplementować interfejs zdefiniowany w Tableklasie sprawia, że wszystkie rodzaje sensu.

OldCurmudgeon
źródło
1

Uważam, że klasy abstrakcyjne są użyteczne, ale nie zawsze potrzebne, a klasy zapieczętowane stają się problemem podczas stosowania testów jednostkowych. Nie możesz kpić ani ukrywać zapieczętowanej klasy, chyba że użyjesz czegoś takiego jak Teleriks justmock.

Dan H.
źródło
1
To dobry komentarz, ale nie odpowiada bezpośrednio na pytanie. Zastanów się, czy nie rozwinąć tutaj swoich myśli.
1

Zgadzam się z twoim poglądem. Myślę, że w Javie klasy powinny być domyślnie „ostateczne”. Jeśli nie uczynisz tego ostatecznym, przygotuj go i udokumentuj do rozszerzenia.

Głównym tego powodem jest zapewnienie, że wszelkie wystąpienia twoich klas będą zgodne z interfejsem, który pierwotnie zaprojektowałeś i udokumentowałeś. W przeciwnym razie programista korzystający z twoich klas może potencjalnie stworzyć kruchy i niespójny kod, a następnie przekazać go innym programistom / projektom, czyniąc obiekty twoich klas niewiarygodnymi.

Z bardziej praktycznego punktu widzenia jest to rzeczywiście wada, ponieważ klienci interfejsu twojej biblioteki nie będą mogli dokonywać żadnych poprawek i używać twoich klas w bardziej elastyczny sposób, o jakim początkowo myślałeś.

Osobiście, biorąc pod uwagę cały zły kod jakości, który istnieje (a ponieważ dyskutujemy o tym na bardziej praktycznym poziomie, argumentowałbym, że programowanie w Javie jest bardziej podatne na to). Myślę, że ta sztywność jest niewielką ceną do zapłaty w naszym dążeniu do łatwiejszego utrzymywać kod.

DPM
źródło