Dlaczego wolę kompozycję niż dziedziczenie?


109

Zawsze czytam, że kompozycja ma pierwszeństwo przed dziedziczeniem. Blogu na przeciwieństwie rodzaju , na przykład, opowiada się za pomocą kompozycji przez dziedziczenie, ale nie mogę zobaczyć, jak polimorfizm został osiągnięty.

Mam jednak wrażenie, że kiedy ludzie mówią, że wolą kompozycję, to tak naprawdę wolą kombinację kompozycji i implementacji interfejsu. Jak dostaniesz polimorfizm bez dziedziczenia?

Oto konkretny przykład, w którym używam dziedziczenia. Jak można to zmienić, aby użyć kompozycji i co zyskałbym?

Class Shape
{
    string name;
  public:
    void getName();
    virtual void draw()=0;
}

Class Circle: public Shape
{
    void draw(/*draw circle*/);
}

57
Nie, kiedy ludzie mówią, wolą oni naprawdę oznacza kompozycję wolą skład, nie nigdy kiedykolwiek użyć dziedziczenia . Całe twoje pytanie opiera się na błędnym założeniu. Użyj dziedziczenia, gdy jest to właściwe.
Bill the Lizard


2
Zgadzam się z Billem. Widzę, że dziedziczenie jest powszechną praktyką w tworzeniu GUI.
Prashant Cholachagudda

2
Chcesz użyć kompozycji, ponieważ kwadrat jest tylko kompozycją dwóch trójkątów, w rzeczywistości myślę, że wszystkie kształty inne niż elipsa są tylko kompozycjami trójkątów. Polimorfizm dotyczy zobowiązań umownych i jest w 100% usunięty z dziedziczenia. Kiedy trójkąt ma do czynienia z mnóstwem dziwactw, ponieważ ktoś chciał sprawić, by był w stanie generować piramidy, jeśli odziedziczysz po Trójkącie, dostaniesz to wszystko, nawet jeśli nigdy nie będziesz generować piramidy 3D ze swojego sześciokąta .
Jimmy Hoffa

2
@BilltheLizard Myślę, że wiele osób, które mówią, że tak naprawdę oznacza, że nigdy nie będzie używać dziedziczenia, ale się mylą.
immibis,

Odpowiedzi:


51

Polimorfizm niekoniecznie oznacza dziedziczenie. Często dziedziczenie jest używane jako prosty sposób na implementację zachowania polimorficznego, ponieważ wygodnie jest klasyfikować podobne zachowujące się obiekty jako mające całkowicie wspólną strukturę i zachowanie korzenia. Pomyśl o wszystkich przykładach kodu samochodu i psa, które widziałeś przez lata.

Ale co z obiektami, które nie są takie same. Modelowanie samochodu i planety byłoby bardzo różne, a jednak obaj mogliby chcieć zaimplementować zachowanie Move ().

Właściwie odpowiedziałeś na swoje własne pytanie "But I have a feeling that when people say prefer composition, they really mean prefer a combination of composition and interface implementation.". Wspólne zachowanie można zapewnić za pomocą interfejsów i zestawu behawioralnego.

Co do tego, co jest lepsze, odpowiedź jest nieco subiektywna i naprawdę sprowadza się do tego, jak chcesz, aby twój system działał, co ma sens zarówno kontekstowo, jak i architektonicznie, oraz jak łatwo będzie przetestować i utrzymać.


W praktyce, jak często pojawia się „Polimorfizm poprzez interfejs” i jest uważany za normalny (w przeciwieństwie do wykorzystywania słabej ekspresji). Założę się, że polimorfizm poprzez dziedziczenie był wynikiem starannego zaprojektowania, a nie jakiejś konsekwencji języka (C ++) odkrytego po jego specyfikacji.
samis,

1
Kto nazwałby move () na planecie i samochodzie i uważa to za to samo? Pytanie brzmi: w jakim kontekście powinni móc się przenieść? Jeśli oba są obiektami 2D w prostej grze 2D, mogą odziedziczyć ruch, gdyby były obiektami w masowej symulacji danych, może nie mieć sensu pozwolić im dziedziczyć z tej samej bazy, w związku z czym musisz użyć interfejs
NikkyD,

2
@SamusArin To pokazuje się wszędzie i jest uważane za całkowicie normalne w językach, które obsługują interfejsy. Co masz na myśli mówiąc o „wykorzystaniu ekspresji języka”? Do tego służą interfejsy .
Andres F.,

@AndresF. Właśnie natknąłem się na „Polimorfizm poprzez interfejs”, czytając przykład „Analiza zorientowana obiektowo i projektowanie z aplikacjami”, która zademonstrowała wzorzec obserwatora. Wtedy zrozumiałem moją odpowiedź.
samis

@AndresF. Wydaje mi się, że moja wizja była nieco zaślepiona w związku z tym, jak wykorzystałem polimorfizm w moim ostatnim (pierwszym) projekcie. Miałem 5 typów rekordów pochodzących z tej samej bazy. W każdym razie dzięki za oświecenie.
samis

79

Preferowanie kompozycji to nie tylko polimorfizm. Chociaż jest to częścią tego, i masz rację, że (przynajmniej w nominalnie typowanych językach) ludzie tak naprawdę mają na myśli „wolą kombinację kompozycji i implementacji interfejsu”. Ale powody, dla których preferuje się kompozycję (w wielu okolicznościach) są głębokie.

Polimorfizm polega na tym, że jedna zachowuje się na wiele sposobów. Tak więc, generyczne / szablony są cechą „polimorficzną”, o ile pozwalają one jednemu kawałkowi kodu zmieniać jego zachowanie w zależności od typu. W rzeczywistości ten typ polimorfizmu jest naprawdę najlepiej zachowany i jest ogólnie określany jako polimorfizm parametryczny, ponieważ zmienność jest definiowana przez parametr.

Wiele języków zapewnia formę polimorfizmu zwaną „przeładowaniem” lub polimorfizm ad hoc, w którym wiele procedur o tej samej nazwie jest definiowanych w sposób ad hoc, a jeden z nich jest wybierany przez język (być może najbardziej specyficzny). Jest to najmniej dobrze zachowany rodzaj polimorfizmu, ponieważ nic nie łączy zachowania dwóch procedur oprócz rozwiniętej konwencji.

Trzecim rodzajem polimorfizmu jest polimorfizm podtypu . Tutaj procedura zdefiniowana dla danego typu może również działać na całej rodzinie „podtypów” tego typu. Kiedy implementujesz interfejs lub rozszerzasz klasę, ogólnie deklarujesz zamiar utworzenia podtypu. Prawdziwe podtypy podlegają zasadzie substytucji Liskowa, który mówi, że jeśli możesz udowodnić coś o wszystkich obiektach w nadtypie, możesz to udowodnić we wszystkich instancjach w podtypie. Życie staje się niebezpieczne, ponieważ w językach takich jak C ++ i Java ludzie na ogół nie wymuszają, a często nieudokumentowane założenia dotyczące klas, które mogą, ale nie muszą, być prawdziwe w odniesieniu do ich podklas. Oznacza to, że kod jest pisany tak, jakby więcej można było udowodnić, niż jest w rzeczywistości, co powoduje cały szereg problemów, gdy podtyp jest niedbale.

Dziedziczenie jest w rzeczywistości niezależne od polimorfizmu. Biorąc pod uwagę coś „T”, które ma odniesienie do siebie, dziedziczenie następuje, gdy tworzysz nową rzecz „S” z „T”, zastępując odniesienie „T” do siebie odwołaniem do „S”. Ta definicja jest celowo niejasna, ponieważ dziedziczenie może się zdarzyć w wielu sytuacjach, ale najczęstszym jest podklasowanie obiektu, które powoduje zastąpienie thiswskaźnika wywoływanego przez funkcje wirtualne thiswskaźnikiem do podtypu.

Dziedziczenie jest niebezpieczne, podobnie jak wszystkie bardzo potężne rzeczy, które mogą powodować spustoszenie. Załóżmy na przykład, że zastępujesz metodę podczas dziedziczenia po pewnej klasie: wszystko jest w porządku i dobrze, dopóki inna metoda tej klasy nie przyjmie, że dziedziczona metoda zachowuje się w określony sposób, po tym wszystkim, jak zaprojektował ją autor oryginalnej klasy . Możesz częściowo zabezpieczyć się przed tym, deklarując, że wszystkie metody wywoływane przez inną z metod są prywatne lub nie-wirtualne (końcowe), chyba że są one przeznaczone do zastąpienia. Nawet to nie zawsze jest wystarczająco dobre. Czasami możesz zobaczyć coś takiego (w pseudo Javie, mam nadzieję, że czytelne dla użytkowników C ++ i C #)

interface UsefulThingsInterface {
    void doThings();
    void doMoreThings();
}

...

class WayOfDoingUsefulThings implements UsefulThingsInterface{
     private foo stuff;
     public final int getStuff();
     void doThings(){
       //modifies stuff, such that ...
       ...
     }
     ...
     void doMoreThings(){
       //ignores stuff
       ...
     }
 }

myślisz, że to jest piękne i masz swój własny sposób robienia „rzeczy”, ale używasz dziedziczenia, aby nabyć umiejętność robienia „więcej rzeczy”,

class MyUsefulThings extends WayOfDoingUsefulThings{
     void doThings {
        //my way
     }
}

I wszystko jest dobrze i dobrze. WayOfDoingUsefulThingszostał zaprojektowany w taki sposób, że zastąpienie jednej metody nie zmienia semantyki żadnej innej ... z wyjątkiem czekania, nie, nie było. Wygląda na to, że tak było, ale doThingszmienił się stan zmienny, który miał znaczenie. Tak więc, mimo że nie wywołało żadnych funkcji, które można zastąpić,

 void dealWithStuff(WayOfDoingUsefulThings bar){
     bar.doThings()
     use(bar.getStuff());
 }

teraz robi coś innego niż oczekiwano, gdy go zdasz MyUsefulThings. Co gorsza, możesz nawet nie wiedzieć, że WayOfDoingUsefulThingsskładali te obietnice. Może dealWithStuffpochodzi z tej samej biblioteki WayOfDoingUsefulThingsi getStuff()nie jest nawet eksportowany przez bibliotekę (pomyśl o klasach przyjaciół w C ++). Co gorsza, zostały pokonane statycznych kontrole języka bez uświadomienia sobie: dealWithStuffwziął WayOfDoingUsefulThingstylko upewnić się, że miałoby to getStuff()funkcja, która zachowywała się w określony sposób.

Używanie kompozycji

class MyUsefulThings implements UsefulThingsInterface{
     private way = new WayOfDoingUsefulThings()
     void doThings() {
        //my way
     }
     void doMoreThings() {
        this.way.doMoreThings();
     }
}

przywraca bezpieczeństwo typu statycznego. Ogólnie kompozycja jest łatwiejsza w użyciu i bezpieczniejsza niż dziedziczenie podczas implementacji podtypów. Pozwala również na przesłonięcie ostatecznych metod, co oznacza, że ​​powinieneś swobodnie deklarować wszystko jako ostateczne / nie-wirtualne, z wyjątkiem interfejsów przez zdecydowaną większość czasu.

W lepszym świecie języki automatycznie wstawiałyby płytę główną ze delegationsłowem kluczowym. Większość nie, więc wadą są większe klasy. Chociaż możesz poprosić IDE o napisanie dla ciebie instancji delegującej.

W życiu nie chodzi tylko o polimorfizm. Nie musisz cały czas podtypować. Celem polimorfizmu jest ponowne użycie kodu, ale nie jest to jedyny sposób na osiągnięcie tego celu. Często sensowne jest użycie kompozycji, bez polimorfizmu podtypów, jako sposobu zarządzania funkcjonalnością.

Dziedziczenie behawioralne ma również swoje zastosowania. Jest to jeden z najpotężniejszych pomysłów w informatyce. Wystarczy, że przez większość czasu dobre aplikacje OOP można pisać tylko przy użyciu dziedziczenia interfejsu i kompozycji. Dwie zasady

  1. Zakaz dziedziczenia lub projektowania
  2. Wolę kompozycję

są dobrym przewodnikiem z powyższych powodów i nie ponoszą żadnych znacznych kosztów.


4
Niezła odpowiedź. Podsumowując, próba ponownego wykorzystania kodu z dziedziczeniem jest po prostu złą ścieżką. Dziedziczenie jest bardzo silnym ograniczeniem (imho dodając „moc” jest błędną analogią!) I tworzy silną zależność między dziedziczonymi klasami. Zbyt wiele zależności = zły kod :) Tak więc dziedziczenie (inaczej „zachowuje się jak”) zwykle świeci dla zunifikowanych interfejsów (= ukrywanie złożoności dziedziczonych klas), w innym przypadku pomyśl dwa razy lub użyj kompozycji ...
MaR

2
To dobra odpowiedź. +1. Przynajmniej moim zdaniem wydaje się, że dodatkowe komplikacje mogą być znaczącym kosztem, a to sprawiło, że osobiście wahałem się, czy nie zdecydować się na preferowanie kompozycji. Zwłaszcza, gdy próbujesz stworzyć dużo kodu przyjaznego do testowania jednostkowego poprzez interfejsy, kompozycję i DI (wiem, że to dodaje inne rzeczy), bardzo łatwo jest sprawić, aby ktoś przejrzał kilka różnych plików, aby bardzo dokładnie sprawdził kilka szczegółów. Dlaczego nie wspomina się o tym częściej, nawet jeśli dotyczy tylko kompozycji zamiast zasady projektowania dziedziczonego?
Panzercrisis,

27

Powodem, dla którego ludzie to mówią, jest to, że początkujący programiści OOP, świeżo po swoich wykładach dotyczących polimorfizmu poprzez dziedziczenie, mają tendencję do pisania dużych klas z wieloma metodami polimorficznymi, a potem gdzieś w końcu kończą się nie do utrzymania.

Typowy przykład pochodzi ze świata tworzenia gier. Załóżmy, że masz klasę podstawową dla wszystkich jednostek gry - statku kosmicznego, potworów, pocisków itp .; każdy typ jednostki ma własną podklasę. Podejście dziedziczenie będzie korzystać kilka metod polimorficznych, na przykład update_controls(), update_physics(), draw(), itd., I wdrożyć je dla każdej podklasy. Oznacza to jednak, że łączysz niezwiązaną funkcjonalność: nie ma znaczenia, jak wygląda obiekt, aby go przenieść, i nie musisz nic wiedzieć o jego sztucznej inteligencji, aby go narysować. Zamiast tego podejście kompozycyjne definiuje kilka klas podstawowych (lub interfejsów), np. EntityBrain(Podklasy implementują sztuczną inteligencję lub dane wejściowe gracza), EntityPhysics(podklasy implementują fizykę ruchu) i EntityPainter(podklasy zajmują się rysowaniem) oraz klasa niepolimorficznaEntityktóra zawiera jedno wystąpienie każdego z nich. W ten sposób możesz połączyć dowolny wygląd z dowolnym modelem fizyki i dowolną sztuczną inteligencją, a ponieważ oddzielisz je od siebie, twój kod będzie również znacznie czystszy. Ponadto znikają problemy takie jak „Chcę potwora, który wygląda jak potwór balonowy na poziomie 1, ale zachowuje się jak szalony klaun na poziomie 15”: po prostu weź odpowiednie komponenty i sklej je ze sobą.

Zauważ, że podejście kompozycyjne nadal wykorzystuje dziedziczenie w każdym komponencie; chociaż idealnie byłoby tutaj użyć tylko interfejsów i ich implementacji.

„Rozdzielenie obaw” jest tutaj kluczowym zwrotem: reprezentowanie fizyki, wdrażanie sztucznej inteligencji i rysowanie bytu to trzy obawy, łączenie ich w byt jest czwartym. W podejściu kompozycyjnym każdy problem jest modelowany jako jedna klasa.


1
Wszystko to jest dobre i zalecane w artykule powiązanym z pierwotnym pytaniem. Chciałbym (kiedykolwiek masz czas) podać prosty przykład obejmujący wszystkie te byty, zachowując jednocześnie polimorfizm (w C ++). Lub wskaż mi zasób. Dzięki.
MustafaM,

Wiem, że twoja odpowiedź jest bardzo stara, ale zrobiłem dokładnie to samo, co wspomniałeś w mojej grze, a teraz wybieram kompozycję zamiast metody dziedziczenia.
Sneh

„Chcę potwora, który wygląda jak…”. Jak to ma sens? Interfejs nie daje żadnej implementacji, musiałbyś skopiować kod wyglądu i zachowania w taki czy inny sposób
NikkyD,

1
@NikkyD Bierzesz MonsterVisuals balloonVisualsi tworzysz MonsterBehaviour crazyClownBehavioura Monster(balloonVisuals, crazyClownBehaviour), obok instancji Monster(balloonVisuals, balloonBehaviour)i Monster(crazyClownVisuals, crazyClownBehaviour)instancji, które zostały utworzone na poziomie 1 i poziomie 15
Caleth

13

Podany przykład to taki, w którym dziedziczenie jest naturalnym wyborem. Nie sądzę, aby ktokolwiek twierdził, że kompozycja jest zawsze lepszym wyborem niż dziedziczenie - to tylko wytyczna, która oznacza, że ​​często lepiej jest złożyć kilka stosunkowo prostych obiektów niż stworzyć wiele wysoce specjalistycznych obiektów.

Delegowanie jest jednym z przykładów użycia kompozycji zamiast dziedziczenia. Delegowanie pozwala modyfikować zachowanie klasy bez podklas. Rozważ klasę zapewniającą połączenie sieciowe, NetStream. Podklasą NetStream może być naturalne zaimplementowanie wspólnego protokołu sieciowego, więc możesz wymyślić FTPStream i HTTPStream. Ale zamiast tworzyć bardzo konkretną podklasę HTTPStream dla jednego celu, powiedzmy, UpdateMyWebServiceHTTPStream, często lepiej jest użyć zwykłej starej instancji HTTPStream wraz z delegatem, który wie, co zrobić z danymi otrzymanymi z tego obiektu. Jednym z powodów, dla których jest to lepsze, jest to, że unika się mnożenia klas, które muszą być utrzymywane, ale których nigdy nie będziesz w stanie ponownie wykorzystać.


11

Ten cykl zobaczysz dużo w dyskursie na temat tworzenia oprogramowania:

  1. Jakaś funkcja lub wzorzec (nazwijmy to „Wzorem X”) okazuje się przydatny do określonego celu. Wpisy na blogu są pisane wychwalając cnoty dotyczące wzoru X.

  2. Ten szum powoduje, że niektórzy uważają, że powinieneś używać Wzorca X, gdy tylko jest to możliwe .

  3. Inne osoby denerwują się na widok Wzorca X używanego w kontekstach, w których nie jest to właściwe, i piszą posty na blogu, w których stwierdzają, że nie zawsze należy używać Wzoru X i że w niektórych kontekstach jest szkodliwy.

  4. Luz powoduje, że niektórzy uważają, że Wzór X jest zawsze szkodliwy i nigdy nie należy go używać.

Zobaczysz, że ten cykl szumu / luzu zdarza się przy prawie każdej funkcji, od GOTOwzorców przez SQL do NoSQL i, tak, dziedziczenie. Antidotum ma zawsze brać pod uwagę kontekst .

Po Circlezejściu z Shapejest dokładnie jak dziedziczenie ma być stosowany w językach OO wspierających dziedziczenia.

Praktyczna zasada „wolę kompozycję niż dziedziczenie” jest naprawdę myląca bez kontekstu. Powinieneś preferować dziedziczenie, gdy dziedziczenie jest bardziej odpowiednie, ale preferuj kompozycję, gdy kompozycja jest bardziej odpowiednia. To zdanie jest skierowane do ludzi na drugim etapie cyklu szumu, którzy uważają, że dziedziczenie powinno być stosowane wszędzie. Ale cykl się zmienił i dziś wydaje się, że niektórzy uważają, że dziedziczenie samo w sobie jest złe.

Pomyśl o tym jak młot kontra śrubokręt. Czy wolisz śrubokręt niż młotek? Pytanie nie ma sensu. Powinieneś użyć narzędzia odpowiedniego do zadania, a wszystko zależy od tego, jakie zadanie musisz wykonać.

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.