20.1 Podstawy dziedziczenia

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 W definicji klasy B występuje specyfikator dostępu public. Prócz tego specyfikatora, mogą w tym miejscu wystąpić również specyfikatory private lub protected. Określają one górną granicę dostępności tych składowych klasy pochodnej, które odziedziczone zostały z klasy bazowej (patrz rozdział o dostępności składowych klasy). Oznacza to, że składowe określone w klasie bazowej będą w klasie pochodnej miały dostępność taką samą jak w klasie bazowej lub węższą, jeśli ich dostępność w klasie bazowej była szersza niż ta zadeklarowana w klasie pochodnej. Odziedziczone składowe prywatne w ogóle nie są w klasie pochodnej bezpośrednio dostępne. Mogą jednak być dostępne za pomocą odziedziczonych nieprywatnych metod z klasy bazowej — takie metody znajdują się w zakresie klasy bazowej, więc do jej składowych prywatnych oczywiście mają dostęp.

Składowe prywatne odziedziczone z klasy bazowej, nawet jeśli nie są widoczne w klasie pochodnej, fizycznie wchodzą w skład obiektów klasy pochodnej.

Reasumując:

Przypomnijmy tu, że

Atrybut protected oznacza, że dane pole czy metoda będą dostępne (tak jakby były publiczne) w klasach pochodnych danej klasy, a zachowują się jak prywatne dla wszystkich innych klas i funkcji.

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, BC) 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, yz są publiczne, a składowe (tutaj będące metodami) fff, ggghhh 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 ggghhh, 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::'.

Rozpatrzmy przykład:


P159: inher.cpp     Widoczność przesłoniętych składowych

      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.  }

W klasie A funkcja fun jest publiczna. Klasa B dziedziczy z klasy A, ale ogranicza dostępność składowych odziedziczonych do private. W klasie B funkcja fun jest przesłaniana; ponadto klasa ta definiuje publiczną funkcję pub. Wewnątrz funkcji pub użyta jest nazwa fun. Czego ona dotyczy? Ponieważ funkcja pub jest składową klasy B, więc w jej zakresie nazwa fun odnosi się do wersji zdefiniowanej w tejże klasie, czyli do funkcji zdefiniowanej w liniach 10-12. W definicji tej funkcji wywołana ma być funkcja fun, ale w wersji z klasy bazowej. Ponieważ funkcja fun w klasie bazowej jest publiczna, można ją wywołać z zakresu klasy pochodnej pod warunkiem, że użyjemy nazwy kwalifikowanej (linia 11). Gdybyśmy nie kwalifikowali nazwy fun, to użyta zostałaby wersja z klasy B, co doprowadziłoby w naszym przypadku do nieskończonej rekursji; dzięki kwalifikacji natomiast wywołana zostanie właściwa funkcja:
    a.fun(3)   = 9
    b.pub(3,4) = 25
Zauważ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.

Niejawne rzutowanie w górę zachodzi tylko jeśli klasa pochodna dziedziczy z klasy bazowej publicznie (ze specyfikatorem public). W innych przypadkach takiej konwersji trzeba zażądać jawnie za pomocą operatora static_cast lub dynamic_cast.

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:

Konwersja w drugą stronę, od wskaźnika/referencji do obiektu klasy bazowej do wskaźnika/referencji do obiektu klasy pochodnej musi jednak być zawsze jawna; na przykład (dla klas polimorficznych, o czym za chwilę) za pomocą operatora konwersji dynamicznej (dynamic_cast), jak o tym wspominaliśmy w rozdziale o konwersjach . Taki typ konwersji nazywamy rzutowaniem w dół (ang. downcasting). Nastąpi wtedy dynamicznie, czyli w trakcie wykonania programu, sprawdzenie poprawności takiego rzutowania.

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:

Operowanie tymi konwersjami jest przydatne i konieczne zarówno przy korzystaniu z polimorfizmu, jak i na przykład w konstruktorach i przypisaniach definiowanych w klasach dziedziczących z innych klas.

Rozpatrzmy przykład ilustrujący różne tego rodzaju rzutowania.


P160: cast.cpp     Konwersje wiążące obiekt i podobiekt

      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.  }

Klasa A ma składowe xy. Dziedzicząca klasa B definiuje pole o nazwie x (linia 11), więc zasłania składową x dziedziczoną z A (co, jak wspomnieliśmy, nie jest zalecane!). W programie głównym tworzymy obiekt klasy B. W liniach 16-17 inicjujemy składowe tego obiektu. Zauważmy, że prócz xy w skład obiektu wchodzi też przesłonięta składowa x odziedziczona z  A. Tak więc w zakresie klasy B nazwa x oznacza składową (o wartości 11) zdefiniowaną w tej klasie, a nazwa kwalifikowana A::x oznacza składową x (o wartości 1) odziedziczoną z klasy A. Tak więc b.x wynosi 11, ale b.A::x wynosi 1. Widać to z wydruku
    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 11
Z 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 yA::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 xy 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 (xy z podobiektu typu A) oraz 11 (składowa x dodana w klasie B).


Bardzo ważną właściwością dziedziczenia jest to, że

Nie są dziedziczone konstruktory i destruktor.

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, 23 lutego 2019; 23:59