Dziedziczenie i hierarchie klas
Dziedziczenie jest jednym z najistotniejszych elementów obiektowości.
Dziedziczenie umożliwia pogodzenie dwóch sprzecznych dążeń:
Raz napisany, uruchomiony i przetestowany program powinien zostać w niezmienionej postaci.
Programy wymagają stałego dostosowywania do zmieniających się wymagań użytkownika, sprzętowych itp..
Dziedziczenie umożliwia tworzenie hierarchii klas.
Klasy odpowiadają pojęciom występującym w świecie modelowanym przez program. Hierarchie klas pozwalają tworzyć hierarchie pojęć, wyrażając w ten sposób zależności między pojęciami.
Klasa pochodna (podklasa) dziedziczy po klasie bazowej (nadklasie). Klasę pochodną tworzymy wówczas, gdy chcemy opisać bardziej wyspecjalizowane obiekty klasy bazowej. Oznacza to, że każdy obiekt klasy pochodnej jest obiektem klasy bazowej.
Zalety dziedziczenia:
Jawne wyrażanie zależności między klasami (pojęciami). Np. możemy jawnie zapisać, że każdy kwadrat jest prostokątem, zamiast tworzyć dwa opisy różnych klas.
Unikanie ponownego pisania tych samych fragmentów programu (ang. reuse).
Przykładowa klasa bazowa:
class A{ private: int skl1; protected: int skl2; public: int skl3; };Słowo
protected
, występujące w przykładzie, oznacza że składowe klasy po nim wymienione
są widoczne w podklasach (bezpośrednich i dalszych), nie są natomiast widoczne z zewnątrz1Dokładna semantyka protected
jest nieco bardziej skomplikowana, ale w praktyce nie ma to znaczenia..
class B: public A{ private: int skl4; protected: int skl5; public: int skl6; void m(); };
void B::m(){ skl1 = 1; // Błąd, składowa niewidoczna skl2 = 2; // OK! skl3 = 3; // OK skl4 = skl5 = skl6 = 4; // OK } int main(){ A a; B b; int i; i = a.skl1; // Błąd, składowa niewidoczna i = a.skl2; // Błąd, składowa niewidoczna i = a.skl3; // OK i = a.skl4; // Błąd, nie ma takiej składowej (to samo dla skl5 i skl6) i = b.skl1; // Błąd, składowa niewidoczna i = b.skl2; // Błąd, składowa niewidoczna i = b.skl3; // OK! i = b.skl4; // Błąd, składowa niewidoczna i = b.skl5; // Błąd, składowa niewidoczna i = b.skl6; // OK }
Jak wynika z tego przykładu, w języku C++ nie ma żadnego składniowego wyróżnika klas bazowych - można je definiować tak jak zwykłe klasy.
Jednak jeśli chcemy żeby projektowana przez nas klasa była kiedyś klasą bazową, to już w momencie jej deklarowania należy myśleć o dziedziczeniu,
odpowiednio ustalając, które składowe mają mieć atrybut protected
.
składowe prywatne (private
) są widoczne jedynie w klasie, z której pochodzą, i w funkcjach/klasach z nią zaprzyjaźnionych,
składowe chronione (protected
) są widoczne w klasie, z której pochodzą, i w funkcjach/klasach z nią zaprzyjaźnionych
oraz w jej klasach pochodnych i funkcjach/klasach z nimi zaprzyjaźnionych,
składowe publiczne (public
) są widoczne wszędzie tam, gdzie jest widoczna sama klasa.
Uwaga: obiekty podklas, mimo że nie mają bezpośredniego dostępu do prywatnych odziedziczonych składowych, mają także i te odziedziczone składowe.
Można powiedzieć, że obiekt klasy pochodnej przypomina kanapkę (czy tort), zawierający warstwy pochodzące ze wszystkich nadklas.
W szczególności obiekt b
z przykładu zawiera składową skl1
(choć metody z warstwy B
nie mają do tej składowej dostępu).
Jest tak dlatego, że każdy obiekt klasy pochodnej (tu B
) jest obiektem klasy bazowej (tu A
).
W podklasach można deklarować składowe o takiej samej nazwie jak w nadklasach:
class C { public: int a; void m(); }; class D: public C { public: int a; void m(); };
Kompilator zawsze będzie w stanie rozróżnić, o którą składową chodzi:
void C::m(){ a = 1; // Składowa klasy C } void D::m(){ a = 2; // Składowa klasy D } int main (){ C a; D b; a.a = 2; // Składowa klasy C b.a = 2; // Składowa klasy D a.m(); // Składowa klasy C b.m(); // Składowa klasy D }
To samo dotyczy metod. Można przesłaniać zwykłe metody (co jest mało użyteczne) oraz można przedefiniowywać metody wirtualne (co ma olbrzymie zastosowanie praktyczne zwn. polimorfizm).
Do odwoływania się do składowych z nadklas służy operator zasięgu.
Ma on postać ::
.
Przykład użycia:
void D::m(){ a = C::a; }
Oczywiście stosowanie tych samych nazw w nadklasach i podklasach dla zmiennych obiektowych nie ma sensu (dla metod już ma, p. metody wirtualne).
Ale co zrobić, gdy już tak się stanie i chcemy w podklasie odwołać się do składowej z nadklasy?
Należy użyć operatora zasięgu (::
).
Oto inna definicja D::m()
:
void D::m() { a = C::a; }W podobny sposób można w funkcji odwoływać się do przesłoniętych zmiennych globalnych:
int i; void m(int i){ i = 3; // Parametr ::i = 5; // Zmienna globalna }
Obiekt klasy pochodnej jest obiektem klasy bazowej, chcielibyśmy więc, żeby można było wykorzystywać go wszędzie tam, gdzie można używać obiektów z klasy bazowej. Niestety, nie zawsze to jest możliwe (i nie zawsze ma sens):
A a, &ar=a, *aw; B b, &br=b, *bw; a = b; // OK, A::operator= b = a; // Błąd, co miałoby być wartościami // zmiennych obiektowych występujących w B a w A nie? // Byłoby poprawne po zdefiniowaniu: // B& B::operator=(A&); ar = br; // OK br = ar; // Błąd, tak samo jak b = a; aw = bw; // OK bw = aw; // Błąd, co by miało znaczyć bw->skl6 ?
fA(a); // OK, A::A(&A) fA(b); // OK, A::A(&A) automatyczny konstruktor zadziała fAref(a); // OK fAref(b); // OK fAwsk(&a); // OK fAwsk(&b); // OK fB(a); // Błąd, A ma za mało składowych fB(b); // OK fBref(a); // Błąd, A ma za mało składowych fBref(b); // OK fBwsk(&a); // Błąd, A ma za mało składowych fBwsk(&b); // OK
Zwróćmy uwagę na interesującą konsekwencję reguł zgodności przedstawionych powyżej:
D d; C *cwsk=&d; cwsk->m();jaka funkcja powinna się wywołać?
cwsk
pokazuje na obiekt klasy D
.
Ale kompilator o tym nie wie i wygeneruje kod wywołujący funkcję z klasy C
.
Powrócimy do tego tematu przy okazji funkcji wirtualnych.
Klasa może dziedziczyć po nadklasie na trzy różne sposoby. Określa to słowo wpisane w deklaracji podklasy przed nazwą klasy bazowej. Decyduje ono o tym kto wie, że klasa pochodna dziedziczy po klasie bazowej. Tym słowem może być:
public
: wszyscy wiedzą
protected
: wie tylko klasa pochodna, funkcje/klasy zaprzyjaźnione z nią oraz jej klasy pochodne i funkcje/klasy zaprzyjaźnione z nimi,
private
: wie tylko klasa pochodna i funkcje/klasy z nią zaprzyjaźnione.
Co daje ta wiedza? Dwie rzeczy:
pozwala dokonywać niejawnych konwersji ze wskaźników do podklas na wskaźniki do nadklas,
pozwala dostawać się (zgodnie z omówionymi poprzednio regułami dostępu) do składowych klasy bazowej.
Jeśli pominiemy to słowo, to domyślnie zostanie przyjęte private
(dla struktur public
).
Oto przykłady ilustrujące przedstawione reguły:
class A{ public: int i; // ... }; class B1: public A{}; class B2: protected A{}; class B3: private A{ void m(B1*, B2*, B3*); }; class C2: public B2{ void m(B1*, B2*, B3*); };
void m(B1* pb1, B2* pb2, B3* pb3){ A* pa = pb1; // OK pb1->a = 1; // OK pa = pb2; // Błąd (f nie wie, że B2 jest podklasą A) pb2->a = 1; // Błąd pa = pb3; // Błąd pb3->a = 1; // Błąd }
void C2::m(B1* pb1, B2* pb2, B3* pb3){ A* pa = pb1; // OK pb1->a = 1; // OK pa = pb2; // OK pb2->a = 1; // OK pa = pb3; // Błąd (C2::f nie wie, że B3 jest podklasą A pb3->a = 1; // Błąd }
void B3::m(B1* pb1, B2* pb2, B3* pb3){ A* pa = pb1; // OK pb1->a = 1; // OK pa = pb2; // Błąd (B3::f nie wie, że B2 jest // podklasą A pb2->a = 1; // Błąd pa = pb3; // OK pb3->a = 1; // OK }
Zatem jeśli nazwa klasy bazowej jest poprzedzona słowem protected
, to składowe publiczne tej klasy zachowują
się w klasie pochodnej jak chronione, zaś jeśli nazwa klasy bazowej jest poprzedzona słowem private
,
to jej składowe publiczne i chronione zachowują się w klasie pochodnej jak prywatne.
Przedstawione tu mechanizmy określania dostępu do klasy podstawowej mają zdecydowanie mniejsze znaczenie,
niż omówione poprzednio mechanizmy ochrony dostępu do składowych.
Rozważmy poniższy (uproszczony) przykład:2W tym i w pozostałych przykładach pozwalamy sobie zapisywać identyfikatory z polskimi znakami. Jest to niezgodne ze składnią C++ (taki program się nie skompiluje), ale zwiększa czytelność przykładów, zaś doprowadzenie do zgodności ze składnią C++ jest czysto mechaniczne i nie powinno Czytelnikowi sprawić kłopotu.
class Figura{ // ... protected: int x,y; // położenie na ekranie public: Figura(int, int); void ustaw(int, int); void pokaż(); void schowaj(); void przesuń(int, int); };
Mamy zatem klasę reprezentująca figury geometryczne na ekranie.
Obiekty tej klasy znają swoje położenie na ekranie (atrybuty x
i y
), oraz
potrafią:
zapamiętać nowe położenie, czyli nowe wartości współrzędnych x
i y
(ustaw
),
narysować się na ekranie w bieżącym położeniu (pokaż
),
wymazać się z ekranu w bieżącym położeniu (schowaj
),
przesunąć się na ekranie z bieżącego do wskazanego parametrami położenia (przesuń
).
Ponadto jest zadeklarowany konstruktor. Spróbujmy zapisać implementację tych metod.
Figura::Figura(int n_x, int n_y){ ustaw(x,y); } Figura::ustaw(int n_x, int n_y){ x = n_x; y = n_y; } Figura::przesuń(int n_x, int n_y){ schowaj(); ustaw(n_x, n_y); pokaż(); } Figura::pokaż(){ // Nie umiemy narysować dowolnej figury } Figura::schowaj(){ // j.w. }
Udaje się to tylko częściowo.
Łatwo można zapisać treść ustaw
.
Przesuń
też wydaje się proste.
Natomiast nie wiedząc z jaką figurą mamy do czynienia, nie potrafimy jej ani narysować ani schować.
Spróbujmy zatem zdefiniować jakąś konkretną figurę geometryczną, np. okrąg:
class Okrąg: public Figura{ protected: int promień, public: Okrag(int, int, int); pokaż(); schowaj(); // }; Okrąg::Okrąg(int x, int y, int r): Figura(x,y), promień(r){}
Oczywiście okrąg oprócz współrzędnych musi też znać swój promień, definiujemy więc stosowny atrybut.
W klasie Okrąg
wiemy już co należy wyrysować na ekranie, więc stosując operacje z dostępnej
biblioteki graficznej pokaż
implementujemy np. jako rysowanie okręgu kolorem czarnym, zaś
schowaj
jako rysowanie kolorem tła.
Naturalnie można by zastosować bardziej subtelne algorytmy (szczególnie jeśli chodzi o chowanie),
ale dla naszych rozważań nie ma to znaczenia.
Można teraz zacząć działać z opisanymi wcześniej Okręgami
.
Przykładowy program mógłby wyglądać następująco:
Okrąg o(20, 30, 10); // Okrąg o zadanym położeniu i promieniu o.pokaż(); // Rysuje okrąg o.przesuń(100, 200); // Nie przesuwa !
Niestety tak pieczołowicie przygotowany zestaw klas zdaje się nie działać.
Ale czemu?
Każda z operacji czytana osobno wydaje się być poprawna.
Problem polega oczywiście na tym, że w treści metody przesuń
wywołały się metody
Figura::pokaż
i Figura::ukryj
zamiast Okrąg::pokaż
i Okrąg::ukryj
.
To bardzo nienaturalne, przecież używamy obiektów klasy Okrąg
, która ma te operacje
prawidłowo zdefiniowane.
Czemu nie działa przykład z Okręgiem
?
Czy instrukcja warunkowa lub wyboru jest tu rozwiązaniem?
Różnica między metodami wirtualnymi a funkcjami.
Składnia deklaracji metod wirtualnych.
W podklasie klasy z metoda wirtualną można tę metodę:
zdefiniować na nowo (zachowując sygnaturę),
nie definiować.
Problem wynika stąd, że kompilator kompilując treść metody Figura::przesuń
nie wie o tym,
że kiedyś zostanie zdefiniowana klasa Okrąg
.
Zatem wywołanie metody pokaż
traktuje jako wywołanie metody Figura::pokaż
.
Wydaje się zresztą, że jest to jedyna możliwość, bo przecież moglibyśmy zdefiniować zaraz
klasę Trójkąt
, i wtedy chcielibyśmy, żeby w przesuń
wywołało się nie
Okrąg::pokaż
tylko Trójkąt::pokaż
.
Jak zaradzić temu problemowi?
W tradycyjnym języku programowania jedynym rozwiązaniem byłoby wpisanie
do metod pokaż
i ukryj
w klasie Figura
długich ciągów
instrukcji warunkowych (lub wyboru) sprawdzających w jakim obiekcie te metody
zostały wywołane i wywoływanie na tej podstawie odpowiednich funkcji rysujących.
Takie rozwiązanie jest bardzo niedobre, bo stosując je dostajemy jedną gigantyczną
i wszystko wiedzącą klasę.
Dodanie nowej figury wymagałoby zmian w większości metod tego giganta,
byłoby więc bardzo trudne i łatwo mogłoby powodować błędy.
Ponieważ w programowaniu obiektowym chcemy, żeby każdy obiekt reprezentował jakąś konkretną rzecz z
modelowanego przez nas świata i żeby jego wiedza była w jak największym
stopniu lokalna, musimy mieć w językach obiektowych mechanizm rozwiązujący przedstawiony problem.
Tym mechanizmem są metody wirtualne.
Metoda wirtualna tym różni się od metody zwykłej, że dopiero w czasie wykonywania programu
podejmuje się decyzję o tym, która wersja tej metody zostanie wywołana.
Deklarację metody wirtualnej poprzedzamy słowem virtual
.
Wirtualne mogą być tylko metody (a nie np. funkcje globalne).
Deklarowanie metod wirtualnych ma sens tylko w hierarchiach klas.
Jeśli w klasie bazowej zadeklarowano jakąś metodę jako wirtualną, to w klasie pochodnej można:
Zdefiniować jeszcze raz tę metodę (z inną treścią).
Można wówczas użyć słowa virtual
, ale jest to nadmiarowe.
Ta metoda musi mieć dokładnie tę samą liczbę i typy parametrów oraz wyniku.
Wówczas w tej klasie obowiązuje zmieniona definicja tej metody.
Nie definiować jej ponownie. Wówczas w tej klasie obowiązuje ta sama definicja metody co w klasie bazowej.
Przyjrzyjmy się teraz trochę bardziej abstrakcyjnemu przykładowi, ale za to z głębszą hierarchią klas. Tym razem trójpoziomową. Przekonajmy się, czy te same reguły dotyczą dalszych klas pochodnych.
class A{ public: void virtual m(int); }; class B: public A{ }; class C: public B{ public: void m(int); // Ta metoda jest wirtualna! };
Mamy trzy klasy A, B, C
dziedziczące jedna po drugiej.
W klasie B nie ma deklaracji żadnych składowych, zatem obiekty tej klasy mają wirtualną
metodę m
, odziedziczoną z klasy A.
Dziedziczy się zatem także to, czy metoda jest wirtualna.
W klasie C metoda m
została zdefiniowana ponownie (podmieniona).
Zwróćmy uwagę na to, że mimo braku słowa virtual
w tej klasie i w klasie B
metoda m
jest wirtualna.
int main(){ A *p; p = new A; p->m(3); // A::m() p = new B; p->m(3); // A::m() p = new C; p->m(3); // C::m(), bo *p jest obiektem klasy C
Przedstawiony fragment programu ilustruje niezwykle istotną technikę programowania obiektowego w C++.
Wskaźnik p
jest zadeklarowany jako wskaźnik do klasy, po której dziedziczy cała hierarchia klas.
Następnie na ten wskaźnik przypisywane są adresy obiektów klas dziedziczących (pośrednio lub bezpośrednio)
po użytej w deklaracji wskaźnika klasie.
Ponieważ obiekty podklas są też obiektami nadklas, tak napisany program nie tylko będzie się kompilował,
ale ponadto będzie działał zgodnie z naszymi oczekiwaniami.
Tak zapisany fragment programu ilustruje wykorzystanie polimorfizmu.
// Ale: // ... A a; C c; a = c; a.m(3); // A::m(), bo a jest obiektem klasy A }
Pamiętajmy jednak, że polimorfizm jest możliwy tylko wtedy, gdy używamy wskaźników bądź referencji.
Wynika to stąd, że choć obiekty podklas logicznie są obiektami nadklas, to fizycznie zwykle
mają większy rozmiar.
Zatem zmienna typu nadklasy nie może przechowywać obiektów podklas.
W przedstawionym przykładzie przypisanie a = c;
skopiuje jedynie pola zadeklarowane w klasie
A
i nic więcej.
Zmienna a
nadal będzie zmienną typu A
, czyli będzie przechowywać obiekt typu A
.
Implementacja:
Jest efektywna.
Np. tablica metod wirtualnych.
Mechanizmy kompilacji języków programowania nie wchodzą w zakres tego wykładu, zatem poprzestaniemy tylko na dwóch stwierdzeniach.
Po pierwsze, metody wirtualne są implementowane efektywnie. Zatem nie ma powodów, by unikać ich w swoich programach. Dodatkowy narzut związany z wywołaniem metody wirtualnej odpowiada pojedynczemu sięgnięciu do pamięci. Ponadto (przy typowej implementacji) każdy obiekt przechowuje jeden dodatkowy wskaźnik.
Po drugie, typowa implementacja metod wirtualnych wykorzystuje tablice metod wirtualnych (ang. vtables).
Często tworząc hierarchię klas na jej szczycie umieszcza się jedną (lub więcej) klas, o których wiemy, że nie będziemy tworzyć obiektów tych klas. Możemy łatwo zagwarantować, że tak rzeczywiście będzie, deklarując jedną (lub więcej) metod w tej klasie jako czyste funkcje wirtualne. Składniowo oznacza to tyle, że po ich nagłówku (ale jeszcze przed średnikiem) umieszcza się =0 i oczywiście nie podaje się ich implementacji. O ile w klasie pochodnej nie przedefiniujemy wszystkich takich funkcji, klasa pochodna też będzie abstrakcyjna. W podanym poprzednio przykładzie z figurami, powinniśmy więc napisać:
class Figura{ // ... virtual void pokaż() = 0; virtual void schowaj() = 0; };
Jak pamiętamy obiekt klasy dziedziczącej po innej klasie przypomina kanapkę, tzn. składa się z wielu warstw, każda odpowiadająca jednej z nadklas w hierarchii dziedziczenia. Tworząc taki obiekt musimy zadbać o zainicjowanie wszystkich warstw. Ponadto klasy mogą mieć składowe również będące obiektami klas - je też trzeba zainicjować w konstruktorze. Na szczęście w podklasie musimy zadbać o inicjowanie jedynie:
bezpośredniej nadklasy,
własnych składowych.
tzn. my nie musimy już (i nie możemy) inicjować dalszych nadklas oraz składowych z nadklas. Powód jest oczywisty: to robi konstruktor nadklasy. My wywołując go (w celu zainicjowania bezpośredniej klasy bazowej) spowodujemy (pośrednio) inicjację wszystkich warstw pochodzących z dalszych klas bazowych. Nie musimy inicjować nadklasy, jeśli ta posiada konstruktor domyślny (i wystarczy nam taka inicjacja). Nie musimy inicjować składowych, które mają konstruktor domyślny (i wystarcza nam taka inicjacja).
Składnia inicjacji: po nagłówku konstruktora umieszczamy nazwę nadklasy (składowej), a po niej w nawiasach parametr(y) konstruktora.
Kolejność inicjacji:
najpierw inicjuje się klasę bazową,
następnie inicjuje się składowe (w kolejności deklaracji, niezależnie od kolejności inicjatorów).
(Uniezależnienie od kolejności inicjatorów służy zagwarantowaniu tego, że podobiekty i składowe zostaną zniszczone w odwrotnej kolejności niż były inicjowane.)
Czemu ważna jest możliwość inicjowania składowych:
class A{ /* ... */ public: A(int); A(); }; class B{ A a; public: B(A&); };
Rozważmy następujące wersje konstruktora dla B:
B::B(A& a2){ a = a2; }i
B::B(A& a2): a(a2){};
W pierwszej na obiektach klasy A wykonują się dwie operacje:
tworzenie i inicjacja konstruktorem domyślnym,
przypisanie.
W drugim tylko jedna:
tworzenie i inicjowanie konstruktorem kopiującym.
Tak więc druga wersja konstruktora jest lepsza.
Kolejność wywoływania destruktorów:
destruktor w klasie,
destruktory (niestatycznych) obiektów składowych,
destruktory klas bazowych.
Ważne uwagi:
W korzeniu hierarchii klas musi być destruktor wirtualny.
Uwaga na metody wirtualne w konstruktorach i destruktorach.
Treść destruktora wykonuje się przed destruktorami dla obiektów składowych. Destruktory dla (niestatycznych) obiektów składowych wykonuje się przed destruktorem (-rami) klas bazowych.
W korzeniu hierarchii klas musi być destruktor wirtualny.
W konstruktorach i destruktorach można wywoływać metody klasy, w tym także wirtualne. Ale uwaga: wywołana funkcja będzie tą, zdefiniowaną w klasie konstruktora/destruktora lub jednej z jej klas bazowych, a nie tą, która ją później unieważnia w klasie pochodnej.
Treść automatycznie generowana z plików źródłowych LaTeXa za pomocą oprogramowania wykorzystującego LaTeXML.
strona główna | webmaster | o portalu | pomoc
© Wydział Matematyki, Informatyki i Mechaniki UW, 2009-2010. Niniejsze materiały są udostępnione bezpłatnie na licencji Creative Commons Uznanie autorstwa-Użycie niekomercyjne-Bez utworów zależnych 3.0 Polska.
Projekt współfinansowany przez Unię Europejską w ramach Europejskiego Funduszu Społecznego.
Projekt współfinansowany przez Ministerstwo Nauki i Szkolnictwa Wyższego i przez Uniwersytet Warszawski.