C++ bez cholesterolu

Dziedziczenie

Konsekwencje dziedziczenia

Każdy język obiektowy musi posiadać narzędzia umożliwiające wyprowadzanie innych typów na bazie już istniejących, a przynajmniej możliwości określania jakichkolwiek związków hierarchicznych między typami (w najgorszym wypadku - możliwości przekazywania sobie obiektom nawzajem rozkazów wykonania operacji, ale może nie komplikujmy aż tak - w C++ zdecydowano się na najprostsze rozwiązanie). Stosowanie tych narzędzi nie determinuje jeszcze programowania obiektowego co prawda, ale przecież C++ nie jest językiem służącym do programowania obiektowego (jak Smalltalk, czy Java), tylko do programowania. Co prawda konsekwencją takiego podejścia jest konieczność wielokrotnie jawnego określania, że rzeczywiście jakiejś - często ponoszącej koszty dodatkowe - właściwości rzeczywiście chce się używać. Jednak w tym przypadku jeszcze nic takiego się nie dzieje. Dziedziczenie powoduje bowiem tylko inny (często dzięki temu skrócony) zapis definicji typów, możliwość częściowego definiowania operacji i tak zwane "składanie z klocków".

Typem bazowym dla innych typów oczywiście może być tylko struktura (nie unia!). Nie jest to jednak - jak się później przekonamy - takie znów bolesne ograniczenie. Aby wyprowadzić nowy typ na bazie istniejącego (nazwijmy go `A'), należy zadeklarować:

struct B: A {
  // definicje dodatkowe
};

Definicja taka pociąga za sobą odpowiednie konsekwencje. Mianowicie wszystko, co zostało zadeklarowane w strukturze A, znajduje się również w strukturze B. Pamiętajmy jednak, że nie każde A::x staje się w strukturze B dostępne jako B::x (nazywa się to "podleganiem dziedziczeniu"); nie zachowuje się tego mianowicie dla operatorów przypisania i konstruktorów. Te metody są oczywiście dostępne wewnątrz metod struktury B, ale z przedrostkiem `A::'. Powód jest prosty - definicja operatora przypisania i konstruktora kopiującego jest dostosowana do struktury `A' i wcale nie oznacza, że będzie pasowała do struktury `B' (nic nie stoi na przeszkodzie, żeby z nich skorzystać).

Dziedziczenie umożliwia przede wszystkim grupowanie typów (nie mówię `klasyfikację', żeby nie zaperzyć ;). Typ B bowiem nie jest w takiej sytuacji typem jakimś zupełnie nowym, lecz typem pochodnym A. A co za tym idzie, wszelkie operacje, które można wykonać na obiekcie typu A, można wykonać na obiekcie typu B. W związku z tym np. wskaźnik na typ `A' może wskazywać na obiekt typu `B', aczkolwiek będzie go traktował jak obiekt typu `A'. Część obiektu typu `B', która jest sama z siebie obiektem typu `A' nazywamy PODOBIEKTEM.

Przedstawię jeszcze - trochę dla ciekawostki - jak ominąć niemożliwość dziedziczenia po typach ścisłych i innych, które nie mogą być dziedziczone (enumy i unie):

struct Int {
  int in;
  operator int() { return in; }
  Int() {}
  Int( int i ): in( i ) { }
};

Teraz obiekt typu `Int' można śmiało przekazywać do wszystkich funkcji, przyjmujących argument typu `int' (z wbudowanymi operatorami włącznie). Niestety nie powoduje to bynajmniej, że typ `Int' jest pochodną `int' - to jest tylko takie małe "oszustwo". W większości przypadków działa jak trzeba, niestety nie zawsze - pewne właściwości C++ wymagają, aby podawać wartości statyczne typów ścisłych (a *nie* wartości konwertowalne do ścisłych, bo takowe nie mogą być konwertowane na wartości statyczne!). Nie można np. stałymi tego typu oznaczać wymiaru tablic "surowych" (tych zwykłych).

Rzutowanie statyczne

Konsekwencją grupowania typów jest to, że wskaźnik lub referencja do typu podstawowego może przyjmować również obiekty typów dla niego pochodnych. Tak więc na przykład:

struct D: B { ... };
...
B b;
D d;
B* p = &b; // wiadomo
...
p = &d; // obiekt `d' jest traktowany
        // jak obiekt struktury B

Poprzez wskaźnik dla struktury bazowej oczywiście można uzyskiwać dostęp tylko do tych składowych obiektu, które są zadeklarowane w strukturze bazowej. Jednak jeśli mamy obiekt struktury pochodnej przypisany do wskaźnika na strukturę bazową, to można go przekonwertować na obiekt struktury pochodnej, używając poznanego już operatora static_cast:

D* pd = static_cast<D*>( p );

Operator ten może być użyty w sytuacji, kiedy konwersja w odwrotnym kierunku może być wykonana niejawnie. Jednak taka konwersja - w odróżnieniu od dynamic_cast - wykonuje się podczas kompilacji i nie dokonuje żadnego sprawdzenia. Można więc go użyć tylko wtedy, kiedy się jest absolutnie pewnym, że dany wskaźnik rzeczywiście wskazuje na obiekt takiego typu, na jaki się chce konwertować.

Dziedziczenie a hierarchizacja

Dziedziczenie umożliwia wieloraką klasyfikację obiektów. Jeden z najczęściej podawanych ideologicznych przykładów: / Osobowy / Samochód -< Ruchome -< \ Ciężarowy \ Pociąg

Jak widać z tego schematu, obiekt klasy Samochód jest również klasy Ruchome, i takoż obiekt klasy Pociąg, a obiekty klas odpowiednio Osobowy i Ciężarowy są obiektami klasy Samochód jak również klasy Ruchome. Ale również należy pamiętać, że klasa Samochód jest POCHODNĄ (ang. derivative) Ruchome, przez co Ruchome jest klasą PODSTAWOWĄ (ang. base) klasy Samochód. Jest też oczywiście klasą podstawową klasy Pociąg.

W C++ wyglądałoby to tak:

struct Ruchome { ... };
struct Samochód: Ruchome { ... };
struct Pociąg: Ruchome { ... };
struct Osobowy: Samochód { ... };
struct Ciężarowy: Samochód { ... };

Zgodnie z teorią obiektową, klasy podstawowe reprezentują pojęcia bardziej ogólne (nazywa się to "generalizacją" pojęć), natomiast klasy pochodne są już bardziej szczegółowe ("specjalizacja" pojęć). Niestety stwierdzenia te zawężają nieco znaczenie dziedziczenia, a poza tym dotyczą spraw, o których będzie mowa dopiero przy programowaniu obiektowym. W programowaniu hierarchicznym bowiem najważniejszą rzeczą jest rozszerzanie definicji. To znaczy, dodawanie dodatkowych definicji w typie pochodnym do tego, co zostało zdefiniowane w typie bazowym.

Dziedziczenie w C++ ma oczywiście dwie postacie, ale zajmiemy się tą pierwszą najważniejszą. W każdym razie, jeśli klasa D dziedziczy po klasie B w ten sposób:

struct D: B
{ ... };

to w efekcie wszystkie (tzn. prawie wszystkie) deklaracje z klasy B niejako "przechodzą" do klasy D. To oznacza w praktyce, że operacje, które zostały zadeklarowane w klasie B są dostępne dla obiektu klasy D. Nie wszystkie oczywiście, jak pamiętamy z poprzedniego rozdziału o dziedziczeniu.

Dziedziczenie wielorakie

"Skoro masz ojca i matkę ;)" z comp.lang.c++

Język C++ jako jeden z niewielu obiektowych umożliwia dziedziczenie wielorakie (zwane też wielobazowym, choć angielska nazwa "multiple inheritance" najdosłowniej znaczy "dziedziczenie wielokrotne", co może być terminem nieco mylącym). Polega to na tym, że klasa może mieć więcej, niż jedną klasę podstawową.

Z dziedziczeniem po wielu klasach zazwyczaj nie ma specjalnych problemów tak długo jak długo ktoś nie wpadnie na jakiś głupi (typowy dla Microsoftu zresztą) pomysł używania dla takich klas operatora "obleśnego" rzutowania (no ale niech będzie; gdzieś tam jest "oficjalna" informacja, że MFC nie wspiera dziedziczenia wielorakiego - zresztą MFC jest dość stare i powstało w czasach, gdy C++ nie wspierał za dobrze wzorców i nie posiadał operatorów rzutowania, ani RTTI ;). Problem z takimi klasami jest mniej więcej taki, że o ile przy pojedynczym dziedziczeniu obiekty klas pochodnych można traktować jak obiekty klas bazowych (tzn. przyjąć dla nich tą samą wartość LICZBOWĄ wskaźnika), o tyle w przypadku dziedziczenia wielokrotnego można takie założenie przyjąć tylko dla pierwszej klasy w kolejności dziedziczenia. Oczywiście kompilator zadba o wszystko, żeby było poprawnie, pod warunkiem jednak, że nie próbuje się go na upartego oszukiwać, a nie można nazwać inaczej używania obleśnego rzutowania. W przypadku wielokrotnego dziedziczenia, stosowana jest tzw. `delta', czyli przesunięcie początku pod-obiektu bazowego względem początku nad-obiektu (łatwo się domyślić, że dla pojedynczego dziedziczenia delta jest równa zero).

Oczywiście dziedziczenie wielokrotne ma też parę przykrych konsekwencji, np. jeśli dziedziczymy po dwóch klasach, mających każda po metodzie o tej samej nazwie (i liście argumentów!), mogą być drobne problemy z jej użyciem. Jeśli np. D dziedziczy po A i B, to mając A::X() i B::X() kompilator nie może rozstrzygnąć niejednoznaczności po próbie użycia D::X(). Oczywiście jednak wszystkie elementy klas odziedziczonych są normalnie dostępne, więc można stworzyć sobie nową metodę D::X() czy coś w tym stylu.

Jeszcze jedna sprawa, która w szczególności dotyka dziedziczenie wielorakie to kwestia static_cast w rzutowaniu wskaźników. Z założenia static_cast gwarantuje, że wskaźnik pusty po konwersji pozostaje wskaźnikiem pustym. Gdyby więc został "w ciemno" skorygowany o wartość delta podczas tej konwersji, to ten warunek nie zostałby spełniony. Zatem w takich przypadkach (niestety!) static_cast dokonuje sprawdzenia, czy wskaźnik nie jest zerowy. Właściwość ta oczywiście nie dotyczy referencji (mimo tego, że referencja nie może odnosić się do obiektu pobranego za pomocą pustego wskaźnika; tzn. dokładnie to nie powinna, bo kompilator tego nie wykryje).

Dziedziczenie wirtualne

Wyobraźmy sobie sytuację, kiedy jakaś klasa jest dziedziczona pośrednio dwukrotnie. Mamy np. taką klasę X, która tworzy następującą hierarchię:

struct A: X  { ... };
struct B: X  { ... };
struct H: X  { ... };

To jeśli teraz utworzymy sobie klasę `Z' w taki sposób:

struct Z: A, H { ... };

To mamy parę drobnych problemów, związanych z tym, że klasa Z ma de facto dwa pod-obiekty klasy X, identyfikowane jako A::X i H::X. Nie tylko zatem pod-obiekt klasy X występuje wewnątrz obiektu typu Z dwukrotnie, ale również wszelkie odwołania do `X' wewnątrz `Z' będą niejednoznaczne. Oczywiście wcale nie twierdzę, że taki sposób dziedziczenia nie jest sensowny - czasem właśnie tak chcemy zaprojektować naszą klasę (tzn. klasa X jest wtedy niejednoznaczną klasą bazową i obiekt klasy Z nie może być podawany za obiekt klasy X!). Jeśli jednak klasa X ma być normalną "wspólną klasą bazową", jednoznacznie określoną, musimy wykorzystać mechanizm, zwany dziedziczeniem wirtualnym.

struct A: virtual X { ... };

I podobnie definiujemy klasy `B' i `H'. Klasa `Z' wyprowadzona z tak zdefiniowanych klas jest już jednoznacznym potomkiem X i choćby po nie wiadomo ilu klasach odziedziczyła pośrednio X (zakładając, że we wszystkich z nich X jest dziedziczone wirtualnie), pod-obiekt klasy X występuje w obiekcie klasy Z tylko raz. Jak to możliwe?

Techniki rozmieszczenia to już oczywiście kwestia implementacji, ale po co robić z tego tajemnicę :*). Implementacja opisana przez Bjarne Stroustrupa w "Projektowanie i rozwój języka C++" polega na tym, że pod-obiekt klasy X jest normalnie zawarty wewnątrz obiektu, lecz jego delta jest nieistotna. Jest to bowiem rozmieszczenie otwarte. Dostęp do tego pod-obiektu jest wyłącznie za pośrednictwem wskaźnika, który jest jednym z pól klasy - TEJ klasy, która właśnie dziedziczy wirtualnie. Zatem jeśli dziedziczy się taką klasę podstawową pośrednio dwukrotnie, to w obiekcie klasy wyjściowej zostanie umieszczony dwukrotnie, ale nie pod-obiekt klasy X, lecz wskaźnik do takiego pod-obiektu. Fizyczny pod-obiekt klasy X zostanie zaś umieszczony gdzieś indziej w obrębie obiektu, ale gdzie konkretnie to już jest nieistotne - dostęp do niego zawsze będzie możliwy poprzez ten wskaźnik.

Takie dziedziczenie ma jednak też wiele innych implikacji. Konwersja obiektu pochodnego na podstawowy, co "normalnie" robi się przez deltę, tutaj robi się przez wyłuskanie wskaźnika. Jednak konwencja `delty' ma to do siebie, że można taką operację wykonywać w obie strony, czego nie można powiedzieć o wyłuskaniu wskaźnika. Pod-obiekt odziedziczony wirtualnie nie ma za grosz pojęcia o tym, jakiego obiektu większego jest on częścią, czyli "co" na niego wskazuje. To w praktyce oznacza, że rzutowanie statyczne ze wskaźnika na strukturę, która była w strukturze docelowej dziedziczona wirtualnie, jest niemożliwe.

Jednak rzutowanie dynamiczne działa; niezależnie bowiem od tego, jak byłby obiekt wskazywany, w strukturze identyfikacyjnej typu są informacje wystarczające do odtworzenia połączenia. Prawidłowo działają też metody wirtualne, o których będzie więcej w programowaniu obiektowym.