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*/);
}
interfaces
inheritance
composition
MustafaM
źródło
źródło
Odpowiedzi:
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ć.
źródło
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
this
wskaźnika wywoływanego przez funkcje wirtualnethis
wskaź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 #)
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”,
I wszystko jest dobrze i dobrze.
WayOfDoingUsefulThings
został 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, aledoThings
zmienił się stan zmienny, który miał znaczenie. Tak więc, mimo że nie wywołało żadnych funkcji, które można zastąpić,teraz robi coś innego niż oczekiwano, gdy go zdasz
MyUsefulThings
. Co gorsza, możesz nawet nie wiedzieć, żeWayOfDoingUsefulThings
składali te obietnice. MożedealWithStuff
pochodzi z tej samej bibliotekiWayOfDoingUsefulThings
igetStuff()
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:dealWithStuff
wziąłWayOfDoingUsefulThings
tylko upewnić się, że miałoby togetStuff()
funkcja, która zachowywała się w określony sposób.Używanie kompozycji
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
delegation
sł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
są dobrym przewodnikiem z powyższych powodów i nie ponoszą żadnych znacznych kosztów.
źródło
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) iEntityPainter
(podklasy zajmują się rysowaniem) oraz klasa niepolimorficznaEntity
któ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.
źródło
MonsterVisuals balloonVisuals
i tworzyszMonsterBehaviour crazyClownBehaviour
aMonster(balloonVisuals, crazyClownBehaviour)
, obok instancjiMonster(balloonVisuals, balloonBehaviour)
iMonster(crazyClownVisuals, crazyClownBehaviour)
instancji, które zostały utworzone na poziomie 1 i poziomie 15Podany 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ć.
źródło
Ten cykl zobaczysz dużo w dyskursie na temat tworzenia oprogramowania:
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.
Ten szum powoduje, że niektórzy uważają, że powinieneś używać Wzorca X, gdy tylko jest to możliwe .
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.
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
GOTO
wzorców przez SQL do NoSQL i, tak, dziedziczenie. Antidotum ma zawsze brać pod uwagę kontekst .Po
Circle
zejściu zShape
jest 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ć.
źródło