Dlaczego klasy nie powinny być zaprojektowane jako „otwarte”?

44

Podczas czytania różnych pytań o przepełnienie stosu i kodu innych osób ogólny konsensus dotyczący projektowania klas jest zamknięty. Oznacza to, że domyślnie w Javie i C # wszystko jest prywatne, pola są ostateczne, niektóre metody są ostateczne, a czasem klasy są nawet ostateczne .

Chodzi o to, aby ukryć szczegóły implementacji, co jest bardzo dobrym powodem. Jednak protectedw przypadku większości języków OOP i polimorfizmu nie działa to.

Za każdym razem, gdy chcę dodać lub zmienić funkcjonalność klasy, zwykle przeszkadzają mi prywatne i końcowe miejsca. Tutaj ważne są szczegóły implementacji: bierzesz implementację i ją rozszerzasz, dobrze wiesz, jakie są konsekwencje. Ponieważ jednak nie mogę uzyskać dostępu do prywatnych i końcowych pól i metod, mam trzy opcje:

  • Nie rozszerzaj klasy, po prostu obejdź problem prowadzący do bardziej złożonego kodu
  • Skopiuj i wklej całą klasę, zabijając wielokrotnego użytku kod
  • Rozwidlaj projekt

To nie są dobre opcje. Dlaczego nie jest protectedstosowany w projektach napisanych w językach, które go obsługują? Dlaczego niektóre projekty wyraźnie zabraniają dziedziczenia po swoich klasach?

TheLQ
źródło
1
Zgadzam się, miałem ten problem ze składnikami Java Swing, jest naprawdę zły.
Jonas,
20
Obowiązek .: steve-yegge.blogspot.com/2010/07/…
Shog9
3
Istnieje duża szansa, że ​​jeśli masz ten problem, to klasy zostały źle zaprojektowane - a może próbujesz ich używać nieprawidłowo. Nie pytasz klasy o informacje, prosisz ją, aby coś dla ciebie zrobiła - dlatego ogólnie nie powinieneś potrzebować jej danych. Chociaż nie zawsze jest to prawdą, jeśli okaże się, że potrzebujesz dostępu do danych klas, istnieje duże prawdopodobieństwo, że coś poszło nie tak.
Bill K
2
„większość języków OOP?” Wiem o wiele więcej, w których nie można zamknąć zajęć. Pominąłeś czwartą opcję: zmień języki.
kevin cline
@Jonas Jednym z głównych problemów Swinga jest to, że ujawnia on zbyt wiele implementacji. To naprawdę zabija rozwój.
Tom Hawtin - tackline

Odpowiedzi:

55

Projektowanie klas, aby działały poprawnie po rozszerzeniu, szczególnie gdy programista wykonujący rozszerzenie nie do końca rozumie, jak ma działać klasa, wymaga znacznego dodatkowego wysiłku. Nie możesz po prostu wziąć wszystkiego, co prywatne, upublicznić (lub chronić) i nazwać to „otwartym”. Jeśli pozwalasz komuś innemu na zmianę wartości zmiennej, musisz rozważyć, w jaki sposób wszystkie możliwe wartości wpłyną na klasę. (Co jeśli ustawią zmienną na null? Pusta tablica? Liczba ujemna?) To samo dotyczy przyzwalania innym osobom na wywoływanie metody. To wymaga dokładnego przemyślenia.

Nie chodzi więc o to, że klasy nie powinny być otwarte, ale czasami nie warto starać się, aby były one otwarte.

Oczywiście możliwe jest również, że autorzy bibliotek byli po prostu leniwi. Zależy od biblioteki, o której mówisz. :-)

Aaron
źródło
13
Tak, dlatego klasy są domyślnie zapieczętowane. Ponieważ nie można przewidzieć, w jaki sposób klienci mogą rozszerzyć klasę, bezpieczniej jest ją zapieczętować niż zobowiązać się do wspierania potencjalnie nieograniczonej liczby sposobów rozszerzenia klasy.
Robert Harvey
18
Nie musisz przewidywać, w jaki sposób zostanie zastąpiona twoja klasa, musisz tylko założyć, że ludzie rozszerzający twoją klasę wiedzą, co robią. Jeśli przedłużą klasę i ustawią coś na zero, kiedy nie powinno być zerowe, to jest to ich wina, a nie twoja. Jedynym miejscem, w którym ten argument ma sens, są super krytyczne aplikacje, w których coś pójdzie strasznie nie tak, jeśli pole będzie puste.
TheLQ
5
@TheLQ: To całkiem duże założenie.
Robert Harvey
9
@TheLQ Czyja to wina jest bardzo subiektywnym pytaniem. Jeśli ustawią zmienną na pozornie rozsądną wartość, a ty nie udokumentowałeś faktu, że nie było to dozwolone, a twój kod nie wyrzucił wyjątku ArgumentException (lub równoważnego), ale powoduje to, że serwer przechodzi w tryb offline lub prowadzi do exploita bezpieczeństwa, to powiedziałbym, że to tyle samo twoja wina, co ich wina. A jeśli udokumentowałeś prawidłowe wartości i zastosowałeś sprawdzanie argumentów, to włożyłeś dokładnie taki wysiłek, o którym mówię, i musisz zadać sobie pytanie, czy ten wysiłek był naprawdę dobrym wykorzystaniem twojego czasu.
Aaron,
24

Uczynienie wszystkiego domyślnym prywatnym brzmi surowo, ale spójrz na to z drugiej strony: Gdy wszystko jest domyślnie prywatne, uczynienie czegoś publicznym (lub chronionym, co jest prawie tym samym) powinno być świadomym wyborem; jest to umowa autora klasy z tobą, konsumentem, na temat korzystania z klasy. Jest to wygoda dla was obojga: autor może modyfikować wewnętrzne funkcjonowanie klasy, o ile interfejs pozostaje niezmieniony; i dokładnie wiesz, na których częściach klasy możesz polegać, a które mogą ulec zmianie.

Podstawową ideą jest „luźne sprzężenie” (zwane również „wąskimi interfejsami”); jego wartość polega na utrzymaniu złożoności na niskim poziomie. Zmniejszając liczbę sposobów interakcji komponentów, zmniejsza się również wzajemna zależność między nimi; a współzależność jest jednym z najgorszych rodzajów złożoności, jeśli chodzi o utrzymanie i zarządzanie zmianami.

W dobrze zaprojektowanych bibliotekach klasy, które warto rozszerzyć poprzez dziedziczenie, chroniły członków publicznych w odpowiednich miejscach i ukrywają wszystko inne.

tdammers
źródło
To ma sens. Nadal jednak występuje problem, gdy potrzebujesz czegoś bardzo podobnego, ale z 1 lub 2 zmianami w implementacji (wewnętrzne funkcjonowanie klasy). Z trudem też rozumiem, że jeśli przesłonisz klasę, to czy nie zależysz już od jej implementacji?
TheLQ,
5
+1 za zalety luźnego sprzężenia. @TheLQ, to interfejs, na którym powinieneś polegać, a nie implementacja.
Karl Bielefeldt,
19

Wszystko, co nie jest prywatne, powinno istnieć mniej więcej w niezmienionym zachowaniu w każdej przyszłej wersji klasy. W rzeczywistości można to uznać za część interfejsu API, udokumentowane lub nie. Dlatego ujawnienie zbyt wielu szczegółów prawdopodobnie spowoduje później problemy ze zgodnością.

W odniesieniu do „ostatecznego” wzgl. klasy „zapieczętowane”, jednym typowym przypadkiem są klasy niezmienne. Wiele części frameworka zależy od niezmienności łańcuchów. Gdyby klasa String nie była ostateczna, byłoby łatwo (nawet kuszące) stworzyć zmienną podklasę String, która powoduje różnego rodzaju błędy w wielu innych klasach, gdy jest używana zamiast niezmiennej klasy String.

użytkownik 281377
źródło
4
To jest prawdziwa odpowiedź. Inne odpowiedzi dotyczą ważnych rzeczy, ale tak naprawdę istotny fragment jest następujący: wszystko, co nie jest finali / lub privatejest zablokowane i nigdy nie może zostać zmienione .
Konrad Rudolph
1
@Konrad: Dopóki programiści nie zdecydują się przerobić wszystkiego i nie będą kompatybilni wstecz.
JAB
To prawda, ale warto zauważyć, że jest to znacznie słabszy wymóg niż w przypadku publicczłonków; protectedczłonkowie zawierają umowę tylko między klasą, która je ujawnia, a jej bezpośrednimi klasami pochodnymi; dla kontrastu publicczłonkowie kontraktów ze wszystkimi konsumentami w imieniu wszystkich możliwych obecnych i przyszłych klas dziedziczonych.
supercat
14

W OO istnieją dwa sposoby na dodanie funkcjonalności do istniejącego kodu.

Pierwszy to dziedziczenie: bierzesz klasę i czerpiesz z niej. Dziedziczenia należy jednak używać z rozwagą. Powinieneś używać dziedziczenia publicznego głównie wtedy, gdy masz relację isA między bazą a klasą pochodną (np. Prostokąt jest kształtem ). Zamiast tego należy unikać publicznego dziedziczenia za ponowne użycie istniejącej implementacji. Zasadniczo dziedziczenie publiczne służy do upewnienia się, że klasa pochodna ma taki sam interfejs jak klasa podstawowa (lub większa), dzięki czemu można zastosować zasadę podstawienia Liskowa .

Inną metodą dodania funkcjonalności jest użycie delegacji lub kompozycji. Piszesz własną klasę, która wykorzystuje istniejącą klasę jako obiekt wewnętrzny i deleguje do niej część prac związanych z implementacją.

Nie jestem pewien, jaki był pomysł na te wszystkie finały w bibliotece, z której próbujesz skorzystać. Prawdopodobnie programiści chcieli się upewnić, że nie odziedziczysz po nich nowej klasy, ponieważ nie jest to najlepszy sposób na rozszerzenie ich kodu.

knulp
źródło
5
Świetna uwaga dotycząca kompozycji a dziedziczenia.
Zsolt Török
+1, ale nigdy nie podobał mi się przykład „dziedziczny” dla dziedziczenia. Prostokąt i okrąg mogą być dwiema bardzo różnymi rzeczami. Lubię bardziej używać, kwadrat jest prostokątem, który jest ograniczony. Prostokąty mogą być używane jak kształty.
tylermac
@tylermac: rzeczywiście. Przypadek Kwadrat / Prostokąt jest w rzeczywistości jednym z kontrprzykładów LSP. Prawdopodobnie powinienem zacząć myśleć o bardziej skutecznym przykładzie.
knulp
3
Kwadrat / prostokąt jest tylko kontrprzykładem, jeśli zezwolisz na modyfikację.
starblue
11

Większość odpowiedzi ma rację: klasy powinny być zapieczętowane (ostateczne, nieprzekraczalne itp.), Gdy obiekt nie jest zaprojektowany ani przeznaczony do rozszerzenia.

Jednak nie rozważałbym po prostu zamknięcia wszystkiego właściwego projektu kodu. „O” w SOLID oznacza „zasadę otwartego-zamkniętego”, mówiącą, że klasa powinna być „zamknięta” dla modyfikacji, ale „otwarta” dla rozszerzenia. Chodzi o to, że masz kod w obiekcie. Działa dobrze. Dodanie funkcjonalności nie powinno wymagać otwarcia tego kodu i wprowadzenia zmian chirurgicznych, które mogą przerwać wcześniej działające zachowanie. Zamiast tego należy zastosować zastrzyk dziedziczenia lub zależności, aby „rozszerzyć” klasę na dodatkowe czynności.

Na przykład klasa „ConsoleWriter” może pobrać tekst i wysłać go do konsoli. Robi to dobrze. Jednak w pewnym przypadku potrzebujesz również tego samego wyjścia zapisanego w pliku. Otwarcie kodu ConsoleWriter, zmiana jego zewnętrznego interfejsu w celu dodania parametru „WriteToFile” do głównych funkcji oraz umieszczenie dodatkowego kodu do zapisywania plików obok kodu do pisania w konsoli byłoby ogólnie uważane za coś złego.

Zamiast tego możesz zrobić jedną z dwóch rzeczy: możesz wywodzić się z ConsoleWriter w celu utworzenia ConsoleAndFileWriter i rozszerzyć metodę Write (), aby najpierw wywołać implementację podstawową, a następnie zapisać do pliku. Możesz też wyodrębnić interfejs IWriter z ConsoleWriter i ponownie wdrożyć ten interfejs, aby utworzyć dwie nowe klasy; FileWriter i „MultiWriter”, które mogą otrzymać inne IWriters i będą „nadawać” każde wywołanie jego metod do wszystkich podanych pisarzy. Który wybierzesz zależy od tego, czego będziesz potrzebować; proste wyprowadzenie jest, cóż, prostsze, ale jeśli masz niejasne przewidywanie konieczności wysłania wiadomości do klienta sieciowego, trzech plików, dwóch nazwanych potoków i konsoli, śmiało spróbuj wyodrębnić interfejs i utworzyć „Adapter Y”; to'

Teraz, jeśli funkcja Write () nigdy nie została zadeklarowana jako wirtualna (lub została zapieczętowana), teraz masz kłopoty, jeśli nie kontrolujesz kodu źródłowego. Czasami nawet jeśli tak. Na ogół jest to zła sytuacja i frustruje użytkowników interfejsów API o zamkniętym lub ograniczonym źródle bez końca. Ale istnieje uzasadniony powód, dla którego Write () lub cała klasa ConsoleWriter są zapieczętowane; w chronionym polu mogą znajdować się wrażliwe informacje (zastępowane kolejno z klasy podstawowej w celu zapewnienia wspomnianych informacji). Walidacja może być niewystarczająca; kiedy piszesz interfejs API, musisz założyć, że programiści korzystający z twojego kodu nie są mądrzejsi ani życzliwsi niż przeciętny „użytkownik końcowy” ostatecznego systemu; w ten sposób nigdy nie będziesz rozczarowany.

KeithS
źródło
Słuszna uwaga. Dziedziczenie może być dobre lub złe, więc błędem jest twierdzenie, że każda klasa powinna być zapieczętowana. To zależy od konkretnego problemu.
knulp
2

Sądzę, że prawdopodobnie pochodzą od wszystkich osób używających języka oo do programowania c. Patrzę na ciebie, java. Jeśli twój młot ma kształt obiektu, ale chcesz system proceduralny i / lub tak naprawdę nie rozumiesz klas, wtedy twój projekt będzie musiał zostać zamknięty.

Zasadniczo, jeśli zamierzasz pisać w języku oo, Twój projekt musi być zgodny z paradygmatem oo, który obejmuje rozszerzenie. Oczywiście, jeśli ktoś napisał bibliotekę w innym paradygmacie, być może nie powinieneś i tak go rozszerzać.

Spencer Rathbun
źródło
7
BTW, OO i proceduralne zdecydowanie nie wykluczają się wzajemnie. Nie możesz mieć OO bez procedur. Ponadto kompozycja jest w równym stopniu koncepcją OO, co rozszerzeniem.
Michael K,
@Michael oczywiście nie. Stwierdziłem, że możesz chcieć systemu proceduralnego , to znaczy takiego bez koncepcji OO. Widziałem, jak ludzie używają Javy do pisania kodu czysto proceduralnego, i to nie działa, jeśli spróbujesz z nim współdziałać w sposób OO.
Spencer Rathbun
1
Można to rozwiązać, jeśli Java ma funkcje pierwszej klasy. Meh
Michael K,
Nauczanie języków Java lub DOTNet nowym studentom jest trudne. Zazwyczaj uczę Proceduralnego z Procedural Pascal lub „Plain C”, a później przełączam się na OO z Object Pascal lub „C ++”. I zostaw Java na później. Nauczanie programowania za pomocą programów procedur wygląda tak, jakby pojedynczy obiekt (singleton) działał dobrze.
umlcat
@Michael K: W OOP funkcja pierwszej klasy jest tylko obiektem z dokładnie jedną metodą.
Giorgio
2

Ponieważ nie wiedzą nic lepszego.

Oryginalni autorzy prawdopodobnie trzymają się niezrozumienia zasad SOLID, które powstały w zagmatwanym i skomplikowanym świecie C ++.

Mam nadzieję, że zauważysz, że światy ruby, python i perl nie mają problemów, które tutaj twierdzą, że są przyczyną uszczelnienia. Zauważ, że jest to pisanie prostopadłe do dynamicznego. Modyfikatory dostępu są łatwe do pracy w większości (wszystkich?) Językach. Pola C ++ można zmulować rzutując na inny typ (C ++ jest bardziej słaby). Java i C # mogą używać odbicia. Modyfikatory dostępu sprawiają, że wszystko jest na tyle trudne, że nie możesz tego zrobić, chyba że NAPRAWDĘ chcesz.

Pieczętowanie klas i oznaczanie dowolnych członków jawnie narusza zasadę, że proste rzeczy powinny być proste, a trudne rzeczy powinny być możliwe. Nagle rzeczy, które powinny być proste, nie są.

Zachęcam do spróbowania zrozumieć punkt widzenia oryginalnych autorów. Wiele z nich pochodzi z akademickiej idei kapsułkowania, która nigdy nie pokazała absolutnego sukcesu w prawdziwym świecie. Nigdy nie widziałem frameworka ani biblioteki, w której gdzieś programista nie chciałby, aby działał nieco inaczej i nie miał dobrego powodu, aby go zmieniać. Istnieją dwie możliwości, które mogły nękać pierwotnych twórców oprogramowania, którzy przypieczętowali i uczynili członków prywatnymi.

  1. Arogancja - naprawdę wierzyli, że są otwarci na rozszerzenie i zamknięci na modyfikacje
  2. Zadowolenie - wiedzieli, że mogą istnieć inne przypadki użycia, ale postanowili nie pisać dla tych przypadków użycia

Wydaje mi się, że w korporacyjnym frameworku nr 2 prawdopodobnie tak jest. Te frameworki C ++, Java i .NET muszą zostać „wykonane” i muszą przestrzegać określonych wytycznych. Wytyczne te zwykle oznaczają zapieczętowane typy, chyba że typ został wyraźnie zaprojektowany jako część hierarchii typów i członków prywatnych dla wielu rzeczy, które mogą być przydatne dla innych osób .. ale ponieważ nie odnoszą się bezpośrednio do tej klasy, nie są narażone . Wyodrębnienie nowego typu byłoby zbyt kosztowne do obsługi, dokumentowania itp.

Cała idea modyfikatorów dostępu polega na tym, że programiści powinni być chronieni przed sobą. „Programowanie C jest złe, ponieważ pozwala strzelać sobie w stopę”. Jako programista nie zgadzam się z tą filozofią.

Zdecydowanie wolę podejście manglingujące nazwy pytona. W razie potrzeby możesz łatwo (znacznie łatwiej niż refleksja) wymienić szeregowców. Świetny opis na ten temat jest dostępny tutaj: http://bytebaker.com/2009/03/31/python-properties-vs-java-access-modifiers/

Prywatny modyfikator Ruby jest właściwie bardziej chroniony w C # i nie ma prywatnego jako modyfikatora C #. Protected jest trochę inny. Świetne wyjaśnienie tutaj: http://www.ruby-lang.org/en/documentation/ruby-from-other-languages/

Pamiętaj, że Twój język statyczny nie musi być zgodny ze starymi stylami programu do pisania kodów w tamtej przeszłości.

jrwren
źródło
5
„enkapsulacja nigdy nie pokazała absolutnego sukcesu”. Myślałem, że wszyscy zgodzą się, że brak enkapsulacji spowodował wiele problemów.
Joh
5
-1. Głupcy pędzą tam, gdzie aniołowie boją się stąpać. Świętowanie możliwości zmiany prywatnych zmiennych pokazuje poważną wadę twojego stylu kodowania.
riwalk
2
Oprócz tego, że jest dyskusyjny i prawdopodobnie błędny, ten post jest również dość arogancki i obraźliwy. Ładnie wykonane.
Konrad Rudolph
1
Istnieją dwie szkoły myśli, których zwolennicy nie wydają się dobrze dogadywać. Ludowe pisanie statyczne chce, aby łatwo było wnioskować o oprogramowaniu, dynamiczne ludowe chce, aby dodawanie funkcjonalności było łatwe. To, który z nich jest lepszy, zależy w zasadzie od oczekiwanej długości życia projektu.
Joh
1

EDYCJA: Uważam, że klasy powinny być zaprojektowane tak, aby były otwarte. Z pewnymi ograniczeniami, ale nie należy ich zamykać na dziedziczenie.

Dlaczego: „Klasy końcowe” lub „klasy zamknięte” wydają mi się dziwne. Nigdy nie muszę oznaczać jednej z moich klas jako „końcowej” („zapieczętowanej”), ponieważ być może później będę musiał ją dziedziczyć.

Kupuję / pobieram biblioteki stron trzecich z klasami (większość kontroli wizualnych) i naprawdę jestem wdzięczny, że żadna z tych bibliotek nie używała „wersji ostatecznej”, nawet jeśli jest obsługiwana przez język programowania, w którym zostały zakodowane, ponieważ zakończyłem rozszerzanie tych klas, nawet jeśli skompilowałem biblioteki bez kodu źródłowego.

Czasami mam do czynienia z niektórymi okazjonalnymi „zapieczętowanymi” klasami i kończyłem tworzenie nowego „opakowania” zawierającego daną klasę, która ma podobnych członków.

class MyBaseWrapper {
  protected FinalClass _FinalObject;

  public virtual DoSomething()
  {
    // these method cannot be overriden,
    // its from a "final" class
    // the wrapper method can  be open and virtual:
    FinalObject.DoSomething();
  }
} // class MyBaseWrapper


class MyBaseWrapper: MyBaseWrapper {

  public override DoSomething()
  {
    // the wrapper method allows "overriding",
    // a method from a "final" class:
    DoSomethingNewBefore();
    FinalObject.DoSomething();
    DoSomethingNewAfter();
  }
} // class MyBaseWrapper

Klasyfikatory zakresu pozwalają na „uszczelnienie” niektórych funkcji bez ograniczania dziedziczenia.

Kiedy przełączyłem się z Structured Progr. do OOP zacząłem używać członków klasy prywatnej , ale po wielu projektach przestałem używać chronionych , a ostatecznie promować te właściwości lub metody publicznie , jeśli to konieczne.

PS Nie lubię słowa „ostateczny” jako słowa kluczowego w języku C #, wolę raczej używać „zapieczętowanego” jak Java lub „sterylnego”, zgodnie z metaforą dziedziczenia. Poza tym „końcowy” to słowo niejednoznaczne używane w kilku kontekstach.

umlcat
źródło
-1: Powiedziałeś, że lubisz otwarte projekty, ale to nie odpowiada na pytanie, dlatego klasy nie powinny być zaprojektowane jako otwarte.
Joh
@John Przepraszam, nie wytłumaczyłem się dobrze, starałem się wyrazić przeciwną opinię, nie powinno być zapieczętowane. Dzięki za opisanie, dlaczego się nie zgadzasz.
umlcat