Wielodziedziczenie
Korzyści z dziedziczenia mogą być dwojakie:
- Wspólne interfejsy - redukowane jest powielanie kodu wykorzystującego klasy oraz kod taki staje się bardziej jednolity. Często nazywa się to polimorfizmem czasu działania lub dziedziczeniem interfejsu.
- Wspólna implementacja - pozwala zredukować ilość kodu i sprawić, że kod implementacji jest bardziej jednolity. Często nazywa się to dziedziczeniem implementacji.
Klasa może zawierać kombinację tych cech. W tym podrozdziale zajmujemy się bardziej ogólnym zastosowaniem wielu klas bazowych oraz technicznymi aspektami łączenia i używania narzędzi z wielu klas bazowych.
Wiele interfejsów
Klasa abstrakcyjna (np. Ival_box) jest oczywistym sposobem reprezentacji interfejsu. Dla klasy abstrakcyjnej nie mającej zmiennego stanu nie ma wielkiej różnicy między jedno a wielokrotnym użyciem klasy bazowej w hierarchii. Tak naprawdę jako interfejsu w strukturze wielodziedziczenia można użyć każdej klasy nie mającej zmiennego stanu bez poważniejszych komplikacji czy ponoszenia strat wydajności. Najważniejsze jest to, że klasę nie mającą zmiennego stanu można powielać i wspólnie użytkować zależnie od potrzeby. Wykorzystywanie klas abstrakcyjnych jako interfejsów jest powszechne w projektach obiektowych
(w każdym języku rozpoznającym pojęcie interfejsu).
Wiele klas implementacyjnych
Wyobraź sobie symulację ciał na orbicie Ziemi, w której ciała te są reprezentowane jako obiekty klasy Satellite. Obiekt taki zawiera parametry określające orbitę, rozmiar, kształt, albedo, gęstości itd. oraz udostępnia operacje do obliczeń orbitalnych, modyfikowania atrybutów itd. Przykładami satelitów mogą być skały, śmieci ze starych statków kosmicznych, satelity komunikacyjne oraz Międzynarodowa Stacja Kosmiczna. Wszystkie te rodzaje satelitów byłyby obiektami klas pochodnych od klasy Satellite. W każdej z tych derywowanych klas dodane by były dane i funkcje składowe oraz przesłonięte by były niektóre z funkcji wirtualnych klasy Satellite. Teraz przypuśćmy, że chcemy wyświetlić wyniki tych symulacji w postaci graficznej i że mamy
system graficzny zbudowany na zasadzie derywacji obiektów do wyświetlenia ze wspólnej klasy bazowej przechowującej informacje graficzne. Klasa ta zawiera operacje do umieszczania obiektów na ekranie, skalowania ich itd. W celu uogólnienia, uproszczenia i ukrycia szczegółów użytego systemu graficznego klasę umożliwiającą wyświetlanie grafik nazwę Displayed.
Teraz możemy zdefiniować klasę symulacji satelitów komunikacyjnych o nazwie Comm_sat:
public:
//...
};
Reprezentacja graficzna:
Oprócz operacji zdefiniowanych konkretnie dla klasy Comm_sat można używać wszystkich operacji z klas Satellite i Displayed. Na przykład:
{
s.draw(); // Displayed::draw()
Pos p = s.center(); // Satellite::center()
s.transmit(); // Comm_sat::transmit()
}
Analogicznie obiekt klasy Comm_sat można przekazać do funkcji wymagającej obiektu klasy Satellite lub Displayed. Na przykład:
Pos center_of_gravity(const Satellite*);
void g(Comm_sat* p)
{
highlight(p); // przekazanie wskaźnika do części Displayed klasy Comm_sat
Pos x = center_of_gravity(p); // przekazanie wskaźnika do części Satellite klasy Comm_sat
}
Implementacja tego wymaga zastosowania prostej techniki kompilacji, aby zapewnić, że funkcja oczekująca obiektu klasy Satellite „zobaczy” inną część klasy Comm_sat niż funkcje oczekujące obiektu klasy Displayed. Funkcje wirtualne działają normalnie. Na przykład:
public:
virtual Pos center() const = 0; // środek ciężkości
//...
};
class Displayed {
public:
virtual void draw() = 0;
//...
};
class Comm_sat : public Satellite, public Displayed {
public:
Pos center() const override; // przesłania Satellite::center()
void draw() override; // przesłania Displayed::draw()
//...
};
Dzięki temu funkcje Comm_sat::center() i Displayed::draw() będą wywoływane dla obiektu Comm_sat traktowanego odpowiednio jako obiekt klasy Satellite lub Displayed. Dlaczego nie oddzieliłem klas Satellite i Displayed całkowicie? Przecież mogłem je zdefiniować jako składowe klasy Comm_sat. Mogłem też zdefiniować w klasie Comm_sat składowe Satellite* i Displayed* oraz pozostawić kwestię ustanowienia odpowiednich powiązań w gestii konstruktora. W wielu przypadkach zastosowałbym takie rozwiązanie, ale system, z inspiracji którego powstał ten przykład, bazował na osobnych klasach Satellite i Displayed z funkcjami
wirtualnymi. Dostarczyliśmy własne satelity i wyświetlane obiekty poprzez derywację. W szczególności musieliśmy przesłonić wirtualne funkcje składowe klas Satellite i Displayed, aby określić zachowanie własnych obiektów. Jest to jedna z sytuacji, w których trudno jest uniknąćwielodziedziczenia klas bazowych mających stan i implementację. Alternatywne rozwiązania
mogą być trudne do realizacji i obsługi.
Wykorzystanie wielodziedziczenia w celu „sklejenia” dwóch teoretycznie nie powiązanych ze sobą klas w implementacji trzeciej klasy to proste, efektywne i dość ważne, ale niezbyt interesujące rozwiązanie. Zasadniczo chroni programistę przed koniecznością pisania dużej ilości funkcji przekazujących (aby zrekompensować to, że można przesłaniać tylko funkcje zdefiniowane w bazie). Technika ta nie ma znaczącego wpływu na ogólny projekt programu i może czasami być w konflikcie z dążeniem do ukrywania szczegółów implementacyjnych. Ale praktyczna technika nie musi być sprytna. Zasadniczo lubię utworzyć jedną hierarchię implementacji oraz w razie potrzeby dodać kilka klas abstrakcyjnych dostarczających interfejsy. Rozwiązanie takie jest zwykle bardziej elastyczne i pozwala na tworzenie systemów, które łatwo się rozbudowuje. Ale nie zawsze tak jest, szczególnie gdy trzeba użyć istniejących klas, których nie chce się modyfikować (bo np. są częścią jakiejś innej biblioteki).
Gdyby dopuszczalne było tylko pojedyncze dziedziczenie, wachlarz możliwości implementacji klas Displayed, Satellite oraz Comm_sat byłby ograniczony. Klasa Comm_sat mogłaby być rodzajem Satellite lub Displayed, ale nie obu równocześnie (chyba że klasa Satellite byłaby pochodną Displayed lub odwrotnie). Takie rozwiązanie byłoby mniej elastyczne. Po co komuś klasa Comm_sat? W przeciwieństwie do tego, co myśli wiele osób, przykład z klasą Satellite jest prawdziwy, tzn. naprawdę był — a może nadal jest — program zbudowany wg opisanych tu zasad wielodziedziczenia. Program ten służył do badania projektów
systemów komunikacyjnych, w skład których wchodziły satelity, stacje naziemne itd. W istocie klasa Satellite była pochodną wczesnego pojęcia zadania współbieżnego. Przy użyciu takiej symulacji można rozwiązywać problemy dotyczące ruchu ulicznego, reakcji na blokadę stacji naziemnej unieruchomionej przez nawałnicę, porównywania połączeń satelitarnych z naziemnymi itd.
Rozstrzyganie niejednoznaczności
Dwie klasy bazowe mogę zawierać funkcje składowe o takich samych nazwach. Na przykład:
public:
virtual Debug_info get_debug();
//...
};
class Displayed {
public:
virtual Debug_info get_debug();
//...
};
Podczas używania obiektów klasy Comm_sat konieczne jest wybranie w jakiś sposób jednej z tych funkcji. Można na przykład użyć nazwy klasy, z której składowej chcemy użyć:
{
Debug_info di = cs.get_debug(); // błąd: niejednoznaczne
di = cs.Satellite::get_debug(); // OK
di = cs.Displayed::get_debug(); // OK
}
Jednak to powoduje bałagan w kodzie i dlatego najlepiej jest zdefiniować nową funkcję w klasie pochodnej:
public:
Debug_info get_debug() // przesłania Comm_sat::get_debug() i Displayed::get_debug()
{
Debug_info di1 = Satellite::get_debug();
Debug_info di2 = Displayed::get_debug();
return merge_info(di1,di2);
}
//...
};
Funkcja zadeklarowana w klasie pochodnej przesłania wszystkie funkcje o takiej samej nazwie i tego samego typu w klasach bazowych. Jest to zazwyczaj korzystne, bo nie należy tak samo nazywać operacji o różnej semantyce w obrębie jednej klasy. Cechą funkcji wirtualnych jest to, że niezależnie jak je wywołamy, efekt tego wywołania zawsze powinien być taki sam. W implementacji funkcji przesłaniającej często trzeba bezpośrednio określić, której wersji z klasy bazowej chce się użyć. Pełna nazwa, np. Telstar::draw, może odnosić się do funkcji zadeklarowanej w klasie Telstar lub jednej z jej klas bazowych. Na przykład:
public:
void draw()
{
Comm_sat::draw(); // znajduje Displayed::draw
//... jakiś kod...
}
//...
};
Reprezentacja graficzna:
Jeśli dla wywołania funkcji Comm_sat::draw nie zostanie znaleziona funkcja draw zadeklarowana w klasie Comm_sat, kompilator zwróci się w jej poszukiwaniu do klas bazowych, czyli sprawdzi, czy są dostępne funkcje Satellite::draw i Displayed::draw, a w razie potrzeby przeszuka także klasy bazowe tych klas. Jeśli znajdzie tylko jedną funkcję, to jej użyje. W przeciwnym przypadku funkcja Comm_sat::draw nie zostanie znaleziona lub jej wywołanie będzie niejednoznaczne. Gdyby w funkcji Telstar::draw() zastosowano wywołanie draw(), powstałaby nieskończona rekurencja wywołań Telstar::draw().
Można by było napisać Displayed::draw(), ale gdyby ktoś później dodał funkcję Comm_sat::draw(), powstałby trudny do wytropienia błąd. Dlatego z reguły zawsze lepiej jest odwoływać się tylko do bezpośrednich klas nadrzędnych. Można też napisać Comm_sat::Displayed::draw(), ale to byłoby przesadą. A gdybyśmy napisali Satellite::draw(), popełnilibyśmy błąd, bo funkcja draw
znajduje się w gałęzi Displayed tej hierarchii klas.
W przykładzie z funkcją get_debug() przyjęto założenie, że klasy Satellite i Displayed przynajmniej częściowo zostały wspólnie zaprojektowane — idealne trafienie przez przypadek z nazwą, typem zwrotnym, typami argumentów oraz semantyką zdarza się niezwykle rzadko. Znacznie bardziej prawdopodobne jest to, że podobne zadania będą realizowane na różne sposoby, przez co trudno je będzie połączyć w coś użytecznego. Możemy dostać początkowo do dyspozycji klasy SimObj i Widget, których nie możemy modyfikować i które nie zawierają wszystkiego, czego potrzebujemy, a gdy już odpowiadają naszym potrzebom, udostępniają niezgodne interfejsy. W takim przypadku Satellite i Displayed moglibyśmy zdefiniować jako klasy interfejsowe stanowiące jednolitą warstwę do użytku klas wyższego poziomu:
// mapuje narzędzia klasy SimObj na coś łatwiejszego w użyciu przez klasę Satellite
public:
virtual Debug_info get_debug(); // wywołuje funkcję SimObj::DBinf() i pobiera informacje
//...
};
class Displayed : public Widget {
// mapuje narzędzia klasy Widget na coś łatwiejszego do użytku w celu wyświetlenia wyników symulacji Satellite
public:
virtual Debug_info get_debug(); // wczytuje dane z obiektu klasy Widget i tworzy Debug_info
//...
};
Graficzna reprezentacja:
Co ciekawe, dokładnie tak samo postąpilibyśmy, tzn. dodalibyśmy warstwę interfejsową, w celu rozstrzygnięcia dwuznaczności w rzadko spotykanym przypadku, gdy dwie klasy bazowe zawierałyby operacje o identycznych nazwach, ale innej semantyce. Zastanówmy się nad klasycznym (choć raczej czysto hipotetycznym) przypadkiem klasy zawierającej funkcję draw() w grze kowbojskiej:
public:
void draw(); // wyświetla obraz
//...
};
class Cowboy {
public:
void draw(); // wyjmuje pistolet z kabury
//...
};
class Cowboy_window : public Cowboy, public Window {
//...
};
Jak przesłonić te funkcje Cowboy::draw() i Window::draw()? Mają identyczne nazwy i typy, ale kompletnie różne znaczenie (semantykę). Do ich przesłonienia trzeba użyć dwóch różnych funkcji. W języku programowania nie istnieje mechanizm służący do bezpośredniego rozwiązywania takiego egzotycznego problemu, ale można sobie poradzić, definiując klasy pośrednie:
using Window::Window; // dziedziczenie konstruktorów
virtual void win_draw() = 0; // wymuszenie przesłonięcia w klasie pochodnej
void draw() override final { win_draw(); } // wyświetla obraz
};
struct CCowboy : Cowboy{
using Cowboy::Cowboy; // dziedziczenie konstruktorów
virtual void cow_draw() = 0; // wymuszenie przesłonięcia w klasie pochodnej
void draw() override final { cow_draw(); } // wyjmuje pistolet z kabury
};
class Cowboy_window : public CCowboy, public WWindow {
public:
void cow_draw() override;
void win_draw() override;
//...
};
Reprezentacja graficzna:
Gdyby projektant klasy Window lepiej się spisał i zdefiniował funkcję draw() jako const, problemu by w ogóle nie było. To dość typowe.
Wielokrotne użycie klasy bazowej
Gdy każda klasa ma tylko jedną klasę bazową, to hierarchia przypomina drzewo, w którym każda klasa występuje tylko raz. Jeśli klasa ma kilka klas bazowych, to w hierarchii może pojawiać się w kilku miejscach. Wyobraź sobie klasę umożliwiającą zapisywanie w pliku stanu (np. w celu ustawiania punktów wstrzymania, zapisania informacji diagnostycznych albo przechowania
informacji między sesjami), aby go później przywrócić:
virtual string get_file() = 0;
virtual void read() = 0;
virtual void write() = 0;
virtual ~Storable() { }
};
Tak przydatna klasa jest oczywiście potrzebna w wielu miejscach hierarchii. Na przykład:
public:
void write() override;
//...
};
class Receiver : public Storable {
public:
void write() override;
//...
};
class Radio : public Transmitter, public Receiver {
public:
string get_file() override;
void read() override;
void write() override;
//...
};
Można sobie wyobrazić następujące dwie sytuacje:
1. Obiekt klasy Radio ma dwa podobiekty klasy Storable (po jednym dla Transmitter i Receiver).
2. Obiekt klasy Radio ma jeden podobiekt klasy Storable (używany wspólnie przez Transmitter i Receiver).
Domyślnie, jak w tym przykładzie, są dwa podobiekty. Jeśli nie zostanie zaznaczone inaczej, otrzymujemy jedną kopię dla każdego wystąpienia klasy jako bazy. Graficznie można to przedstawić następująco:
Funkcja wirtualna powielonej klasy bazowej może zostać przesłonięta przez (jedną) funkcję w klasie pochodnej. Zazwyczaj funkcja przesłaniająca wywołuje swoje odpowiedniki z klas bazowych, a następnie wykonuje dodatkowe specyficzne działania:
{
Transmitter::write();
Receiver::write();
//... zapisuje informacje dotyczące radia...
}
Język C++. Kompendium wiedzy. Wydanie IV, Autor: Bjarne Stroustrup, Wydawnictwo: Helion