Definiując klasę pochodną definiujemy typ danych rozszerzający typ określany przez klasę bazową, a więc tę, z której klasa dziedziczy. Obiekty klasy pochodnej będą zawierać te składowe, które zawierają obiekty klasy bazowej, i, choć niekoniecznie, dodatkowe składowe, których nie było w klasie bazowej. Klasa pochodna może też dodawać nowe metody lub zmieniać implementację metod odziedziczonych ze swojej klasy bazowej.
Zatem klasa bazowa jest bardziej ogólna, modeluje pewien fragment rzeczywistości na wyższym poziomie abstrakcji. Klasa pochodna jest bardziej szczegółowa, mniej abstrakcyjna. Tak więc, na przykład, pojęcie mebel jest bardziej abstrakcyjne, zaś krzesło bardziej konkretne: zatem klasa opisująca krzesła dziedziczyłaby z klasy opisującej meble: Mebel ← Krzeslo. Mogłaby dodać, na przykład, składową opisującą ilość nóg, której w bardziej ogólnej klasie Mebel nie było, bo nie każdy mebel ma nogi.
Zauważmy tu, że zaznaczając dziedziczenie za pomocą strzałek, jak to zrobiliśmy powyżej, rysujemy te strzałki w kierunku od klasy pochodnej do klasy bazowej.
Składnia deklaracji/definicji klas pochodnych jest następująca:
class A { // ... }; class B : public A { // ... }; class C : B { // ... };Klasy bazowe dla danej klasy deklarujemy na liście dziedziczenia umieszczonej po nazwie klasy i dwukropku, a przed definicją (ciałem) klasy. Klas bazowych może być kilka: ich nazwy umieszczamy wtedy na liście oddzielając je przecinkami. Powyższy zapis oznacza, że
Reasumując:
Brak specyfikatora na liście dziedziczenia, jak w definicji klasy C z powyższego przykładu, jest równoważny z określeniem specyfikatora private.
Zauważmy, że w powyższym przykładzie dziedziczenie z klasy C miałoby już niewielki sens, bo w obiektach ewentualnej klasy dziedziczącej żadne składowe z klas bazowych (w tym przypadku A, B i C) nie byłyby w ogóle widoczne.
Jeśli w klasie pochodnej zawęziliśmy dostępność pól/metod specyfikatorem protected lub private w definicji klasy (po dwukropku, a przed ciałem klasy), to można tę dostępność przywrócić indywidualnie dla wybranych pól/metod, podając ich identyfikatory w postaci kwalifikowanych nazw w odpowiednich sekcjach. Podkreślmy jeszcze raz: nazwy, a nie pełne deklaracje. Kwalifikowane, czyli wraz z nazwą klasy bazowej.
W poniższym przykładzie w klasie A składowe x, y i z są publiczne, a składowe (tutaj będące metodami) fff, ggg i hhh są chronione. Składowa k jest prywatna i nie będzie bezpośrednnio widoczna w klasie pochodnej.
class A { double k; public: int x, y, z protected: double fff(int); double ggg(int); double hhh(int); // ... };Niech teraz klasa B będzie zdefiniowana tak:
1. class B : private A { 2. public: 3. A::x; 4. A::y; 5. 6. protected: 7. A::fff; 8. // ... 9. };W klasie B dostępność dziedziczonych składowych klasy A zawężona jest do poziomu private (linia 1; ten sam efekt można było zapewnić nie podając specyfikatora dostępności w ogóle, gdyż dostępność private jest domyślna). Następnie jednak przywracana jest dostępność publiczna dla składowych x i y (linie 2-4). Zauważmy, że nie przywracamy takiej dostępności składowej z, tak więc w klasie B stanie się ona prywatna. Zauważmy też, że w liniach 3 i 4 wymienieliśmy tylko kwalifikowane nazwy, a nie deklaracje — nie podaliśmy na przykład typu pól.
W liniach 6-7 przywróciliśmy dostępność chronioną dla składowej fff. Tu również podaliśmy tylko kwalifikowaną nazwę, a nie deklarację funkcji — nie ma tu listy parametrów czy określenia typu wartości zwracanej. Metody ggg i hhh, którym dostępności chronionej nie przywróciliśmy, stają się w klasie B prywatne.
Prócz odziedziczonych składowych w klasie pochodnej można definiować własne pola i metody. Tak więc obiekt klasy pochodnej nigdy nie będzie mniejszy niż obiekt klasy bazowej: jak wspomnieliśmy, zawiera bowiem zawsze kompletny podobiekt klasy bazowej i często dodatkowe składowe, których w klasie bazowej nie było.
W klasie pochodnej można definiować składowe o tych samych nazwach co składowe klasy bazowej. Mówimy wtedy o przesłanianiu pól i metod (przedefiniowywaniu, nadpisywaniu, przekrywaniu, przykrywaniu ...; ang. overriding). Przesłaniania pól lepiej nie stosować, bo prowadzi to do chaosu trudnego do opanowania. Natomiast przesłanianie metod jest fundamentalnym narzędziem programowania obiektowego.
Metody/pola z klasy bazowej (na przykład klasy A) mają zakres tejże klasy bazowej; na przykład mają dostęp do składowych prywatnych tej klasy. Natomiast zakres klasy pochodnej jest zawarty w zakresie klasy podstawowej. Znaczy to, że jeśli te pola i metody nie są prywatne i nie zostały w klasie pochodnej przedefiniowane (przesłonięte), to w klasie pochodnej (na przykład B) są również widoczne bezpośrednio (a więc nie trzeba stosować dla nich nazw kwalifikowanych). Jeśli natomiast w klasie pochodnej zostały przesłonięte, to zakresem takich pól/metod będzie klasa pochodna B: w tej klasie dostępne są bezpośrednio „wersje” przedefiniowane. Oczywiście składowe prywatne z klasy bazowej nie są dostępne w zakresie klasy B. Natomiast do składowych nieprywatnych z klasy A, przesłoniętych w klasie pochodnej B, można się wciąż odwołać z zakresu klasy B poprzez jawną kwalifikację nazwy za pomocą operatora zakresu klasowego, a więc, w naszym przypadku, poprzedzając nazwę wskazaniem zakresu ' A::'.
1. #include <iostream> 2. using namespace std; 3. 4. class A { 5. public: 6. int fun(int x) { return x*x; } 7. }; 8. 9. class B : private A { 10. int fun(int x, int y) { 11. return A::fun(x) + y*y; 12. } 13. public: 14. int pub(int x, int y) { return fun(x,y); } 15. }; 16. 17. int main() { 18. A a; 19. B b; 20. cout << "a.fun(3) = " << a.fun(3) << endl; 21. cout << "b.pub(3,4) = " << b.pub(3,4) << endl; 22. // cout << "b.fun(3,4) = " << b.fun(3,4) << endl; 23. }
a.fun(3) = 9 b.pub(3,4) = 25Zauważmy, że na rzecz obiektu klasy A możemy wywołać funkcję fun (linia 20), bo w tej klasie funkcja ta, zdefiniowana w linii 6, jest publiczna. Natomiast na rzecz obiektu klasy B (wykomentowana linia 22) tego zrobić nie możemy, bo w klasie B funkcja fun jest prywatna. Możemy natomiast wywołać publiczną funkcję pub, a ta, jako metoda klasy B, ma w swoim zakresie prywatną wersję funkcji fun zdefiniowaną w tej klasie.
Jak już mówiliśmy, obiekt klasy pochodnej jest obiektem klasy bazowej, uzupełnionym ewentualnie przez dodatkowe składowe. W tym sensie może być w wielu sytuacjach traktowany jak gdyby był obiektem klasy bazowej (tak jak w Javie i innych językach obiektowych).
Na przykład wskaźnik lub referencja do obiektu klasy pochodnej może być użyty tam, gdzie oczekiwany jest wskaźnik (referencja) do obiektu klasy bazowej — wskazywanym obiektem będzie wówczas podobiekt klasy bazowej zawarty w obiekcie klasy pochodnej. Taka konwersja, zwana rzutowaniem w górę (ang. upcasting) jest standardowa i może być wykonywana niejawnie.
Mechanizm, o którym wspomnieliśmy, ma dla programowania obiektowego fundamentalne znaczenie i w dalszym ciągu znajdziemy wiele przykładów na jego zastosowanie. Dzięki niemu:
Zauważmy, że mówimy tu o wskaźnikach i referencjach, a nie o konwersji samych obiektów. Na przykład, jeśli parametrem funkcji jest obiekt klasy bazowej A (przekazywany przez wartość, a nie przez wskaźnik lub referencję), to jako argumentu nie powinniśmy używać obiektu klasy pochodnej B. Wynika to choćby z faktu, że obiekty klasy pochodnej i bazowej zajmują na stosie różną liczbę bajtów, a więc aby takie wywołanie zrealizować obiekt musiałby zostać „przycięty” (do zawartego w nim podobiektu typu A), co czasem daje oczekiwane rezultaty, a czasem zupełnie nieoczekiwane...
Często zachodzi potrzeba definiowania wskaźników lub referencji do podobiektu klasy bazowej zawartego w obiekcie klasy pochodnej lub odwrotnie. Niech A będzie klasą bazową, a B klasą pochodną. Wtedy:
Rozpatrzmy przykład ilustrujący różne tego rodzaju rzutowania.
1. #include <iostream> 2. using namespace std; 3. 4. struct A { 5. int x; 6. int y; 7. A() : x(1), y(2) {} 8. }; 9. 10. struct B : public A { 11. int x; // ???? 12. }; 13. 14. int main() { 15. B b, *pb = &b; 16. b.x = 11; 17. b.y = 12; 18. 19. cout << "b.x=" << b.x << " b.y=" 20. << b.y << " b.A::x=" << b.A::x 21. << " b.A::y=" << b.A::y << endl; 22. 23. cout << "\n pb->x=" << pb ->x << endl; 24. cout << "((A*)pb)->x=" << ((A*)pb)->x << endl; 25. cout << " b.x=" << b.x << endl; 26. cout << " ((A&)b).x=" << ((A&)b).x << endl; 27. cout << "((A*)&b)->x=" << ((A*)&b)->x << endl; 28. 29. A* pa = new B; 30. ((B&)*pa).x = 11; 31. 32. cout << "\n (*pa).x=" << (*pa).x << endl; 33. cout << "((B&)*pa).x=" << ((B&)*pa).x << endl; 34. cout << " pa->x=" << pa->x << endl; 35. cout << "((B*)pa)->x=" << ((B*)pa)->x << endl; 36. 37. cout << "\nsizeof(b) = " << sizeof b << endl; 38. int* t = (int*) &b; 39. cout << t[0] << " " << t[1] << " " << t[2] << endl; 40. }
b: x=11 y=12 b.A::x=1 b.A::y=12 pb->x=11 ((A*)pb)->x=1 b.x=11 ((A&)b).x=1 ((A*)&b)->x=1 (*pa).x=1 ((B&)*pa).x=11 pa->x=1 ((B*)pa)->x=11 sizeof(b) = 12 1 12 11Z wydruku widzimy też, że jeśli pb zrzutujemy do typu A*, to wartością ((A*)pb)->x jest 1, czyli wyłuskiwana jest wartość x z podobiektu klasy A. Podobnie (A&)b jest referencją do tego podobiektu.
Ponieważ składowa y nie została przesłonięta, więc w zakresie klasy B nazwy y i A::y ozaczają tę samą zmienną.
W linii 29 tworzymy obiekt klasy B, ale wskaźnik do niego zapisujemy w zmiennej pa typu A*. Na wydruku widzimy teraz efekt rzutowania w dół: pa jest wskaźnikiem typu A* wskazującym obiekt klasy B. Jej typem jest A*, a zatem pa->x odnosi się do składowej x w podobiekcie klasy A zawartym w obiekcie b. Po zrzutowaniu do typu wskaźnikowego B*, wskazywaną składową o nazwie x jest jednak składowa o tej nazwie z zakresu B (linia 35).
W liniach 37-39 sprawdzamy jaki jest rozmiar obiektu typu B. Wynosi on 12 bajtów, co odpowiada trzem liczbom typu int — są to x i y z podobiektu klasy A oraz x dodane w klasie B (UWAGA: ten fragment może być zależny od architektury komputera i użytego kompilatora!). Traktując adres obiektu jak adres tablicy liczb całkowitych (linia 38), "możemy się przekonać, że rzeczywiście obiekt zawiera liczby 1, 12 (x i y z podobiektu typu A) oraz 11 (składowa x dodana w klasie B).
Bardzo ważną właściwością dziedziczenia jest to, że
Z drugiej strony, właśnie konstruktory i destruktory pełnią ważną rolę, szczególnie dla klas zawierających pola wskaźnikowe, kiedy logiczna zawartość obiektów nie wchodzi fizycznie w ich skład. Zatem problemy związane z konstrukcją obiektów klas pochodnych i ich destrukcją omówimy teraz osobno.
T.R. Werner, 21 lutego 2016; 20:17