LEKCJA 35: O ZASTOSOWANIU DZIEDZICZENIA. ________________________________________________________________ Z tej lekcji dowiesz się, do czego w praktyce programowania szczególnie przydaje się dziedziczenie. ________________________________________________________________ Dzięki dziedziczeniu programista może w pełni wykorzystać gotowe biblioteki klas, tworząc własne klasy i obiekty, jako klasy pochodne wazględem "fabrycznych" klas bazowych. Jeśli bazowy zestw danych i funkcji nie jest adekwatny do potrzeb, można np. przesłonić, rozbudować, bądź przebudować bazową metodę dzięki elastyczności C++. Zdecydowana większość standardowych klas bazowych wyposażana jest w konstruktory. Tworząc klasę pochodną powinniśmy pamiętać o istnieniu konstruktorów i rozumieć sposoby przekazywania argumentów obowiązujące konstruktory w przypadku bardziej złożonej struktury klas bazowych-pochodnych. PRZEKAZANIE PARAMETRÓW DO WIELU KONSTRUKTORÓW. Klasy bazowe mogą być wyposażone w kilka wersji konstruktora. Dopóki nie przekażemy konstruktorowi klasy bazowej żadnych argumentów - zostanie wywołany (domyślny) pusty konstruktor i klasa bazowa będzie utworzona z parametrami domyślnymi. Nie zawsze jest to dla nas najwygodniejsza sytuacja. Jeżeli wszystkie, bądź choćby niektóre z parametrów, które przekazujemy konstruktorowi obiektu klasy pochodnej powinny zostać przekazane także konstruktorowi (konstruktorom) klas bazowych, powinniśmy wytłumaczyć to C++. Z tego też powodu, jeśli konstruktor jakiejś klasy ma jeden, bądź więcej parametrów, to wszystkie klasy pochodne względem tej klasy bazowej muszą posiadać konstruktory. Dla przykładu dodajmy konstruktor do naszej klasy pochodnej Cpochodna: class CBazowa1 { public: CBazowa1(...); //Konstruktor }; class CBazowa2 { public: CBazowa2(...); //Konstruktor }; class Cpochodna : public CBazowa1, CBazowa2 //Lista klas { public: Cpochodna(...); //Konstruktor }; main() { Cpochodna Obiekt(...); //Wywolanie konstruktora ... W momencie wywołania kostruktora obiektu klasy pochodnej Cpochodna() przekazujemy kostruktorowi argumenty. Możemy (jeśli chcemy, nie koniecznie) przekazać te argumenty konstruktorom "wcześniejszym" - konstruktorom klas bazowych. Ta możliwość okazuje się bardzo przydatna (niezbędna) w środowisku obiektowym - np. OWL i TVL. Oto prosty przykład definiowania konstruktora w przypadku dziedziczenia. Rola konstruktorów będzie polegać na trywialnej operacji przekazania pojedynczego znaku. class CBazowa1 { public: CBazowa1(char znak) { cout << znak; } }; class CBazowa2 { public: CBazowa2(char znak) { cout << znak; } }; class Cpochodna : public CBazowa1, CBazowa2 { public: Cpochodna(char c1, char c2, char c3); }; Cpochodna::Cpochodna(char c1,char c2,char c3) : CBazowa1(c2), CBazowa2(c3) { cout << c1; } Konstruktor klasy pochodnej pobiera trzy argumenty i dwa z nich: c2 --> przekazuje do konstruktora klasy CBazowa1 c3 --> przekazuje do konstruktora klasy CBazowa2 Sposób zapisu w C++ wygląda tak: Cpochodna::Cpochodna(char c1,char c2,char c3) : CBazowa1(c2), CBazowa2(c3) Możemy zatem przekazać parametry "w tył" do konstruktorów klas bazowych w taki sposób: kl_pochodna::kl_pochodna(lista):baza1(lista), baza2(lista), ... gdzie: lista - oznacza listę parametrów odpowiedniego konstruktora. W takiej sytuacji na liście argumentów konstruktorów klas bazowych mogą znajdować się także wyrażenia, przy założeniu, że elementy tych wyrażeń są widoczne i dostępne (np. globalne stałe, globalne zmienne, dynamicznie inicjowane zmienne globalne itp.). Konstruktory będą wykonywane w kolejności: CBazowa1 --> CBazowa2 --> Cpochodna Dzięki tym mechanizmom możemy łatwo przekazywać argumenty "wstecz" od konstruktorów klas pochodnych do konstruktorów klas bazowych. FUNKCJE WIRTUALNE. Działanie funkcji wirtualnych przypomina rozbudowę funkcji dzięki mechanizmowi overloadingu. Jeśli, zdefiniowaliśmy w klasie bazowej funkcję wirtualną, to w klasie pochodnej możemy definicję tej funkcji zastąpić nową definicją. Przekonajmy się o tym na przykładzie. Zacznijmy od zadeklarowania funkcji wirtualnej (przy pomocy słowa kluczowego virtual) w klasie bazowej. Zadeklarujemy jako funkcję wirtualną funkcję oddychaj() w klasie CZwierzak: class CZwierzak { public: void Jedz(); virtual void Oddychaj(); }; Wyobraźmy sobie, że chcemy zdefiniować klasę pochodną CRybka Rybki nie oddychają w taki sam sposób, jak inne obiekty klasy CZwierzak. Funkcję Oddychaj() trzeba zatem będzie napisać w dwu różnych wariantach. Obiekt Ciapek może tę funkcję odziedziczyć bez zmian i sapać spokojnie, z Sardynką gorzej: class CZwierzak { public: void Jedz(); virtual void Oddychaj() { cout << "Sapie..."; } }; class CPiesek : public CZwierzak { char imie[30]; } Ciapek; class CRybka char imie[30]; public: void Oddychaj() { cout << "Nie moge sapac..."; } } Sardynka; Zwróć uwagę, że w klasie pochodnej w deklaracji funkcji słowo kluczowe virtual już nie występuje. W klasie pochodnej funkcja CRybka::Oddychaj() robi więcej niż w przypadku "zwykłego" overloadingu funkcji. Funkcja CZwierzak::Oddychaj() zostaje "przesłonięta" (ang. overwrite), mimo, że ilość i typ argumentów. pozostaje bez zmian. Taki proces - bardziej drastyczny, niż overloading nazywany jest przesłanianiem lub nadpisywaniem funkcji (ang. function overriding). W programie przykładowym Ciapek będzie oddychał a Sardynka nie. [P127.CPP] # include class CZwierzak { public: void Jedz(); virtual void Oddychaj() {cout << "\nSapie...";} }; class CPiesek : public CZwierzak { char imie[30]; } Ciapek; class CRybka char imie[30]; public: void Oddychaj() {cout << "\nSardynka: A ja nie oddycham.";} } Sardynka; void main() { Ciapek.Oddychaj(); Sardynka.Oddychaj(); } Funkcja CZwierzak::Oddychaj() została w obiekcie Sardynka przesłonięta przez funkcję CRybka::Oddychaj() - nowszą wersję funkcji-metody pochodzącą z klasy pochodnej. Overloading funkcji zasadzał się na "typologicznym pedantyźmie" C++ i na dodatkowych informacjach, które C++ dołącza przy kompilacji do funkcji, a które dotyczą licznby i typów argumentów danej wersji funkcji. W przypadku funkcji wirtualnych jest inaczej. Aby wykonać przesłanianie kolejnych wersji funkcji wirtualnej w taki sposób, funkcja we wszystkich "pokoleniach" musi mieć taki sam prototyp, tj. pobierać taką samą liczbę parametrów tych samych typów oraz zwracać wartość tego samego typu. Jeśli tak się nie stanie, C++ potraktuje różne prototypy tej samej funkcji w kolejnych pokoleniach zgodnie z zasadami overloadingu funkcji. Zwróćmy tu uwagę, że w przypadku funkcji wirtualnych o wyborze wersji funkcji decyduje to, wobec którego obiektu (której klasy) funkcja została wywołana. Jeśli wywołamy funkcję dla obiektu Ciapek, C++ wybierze wersję CZwierzak::Oddychaj(), natomiast wobec obiektu Sardynka zostanie zastosowana wersja CRybka::Oddychaj(). W C++ wskaźnik do klasy bazowej może także wskazywać na klasy pochodne, więc zastosowanie funkcji wirtualnych może dać pewne ciekawe efekty "uboczne". Jeśli zadeklarujemy wskaźnik *p do obiektów klasy bazowej CZwierzak *p; a następnie zastosujemy ten sam wskaźnik do wskazania na obiekt klasy pochodnej: p = &Ciapek; p->Oddychaj(); ... p = &Sardynka; p->Oddychaj(); zarządamy w taki sposób od C++ rozpoznania właściwej wersji wirtualnej metody Oddychaj() i jej wywołania we właściwym momencie. C++ może rozpoznać, którą wersję funkcji należałoby zastosować tylko na podstawie typu obiektu, wobec którego funkcja została wywołana. I tu pojawia się pewien problem. Kompilator wykonując kompilcję programu nie wie, co będzie wskazywał pointer. Ustawienie pointera na konkretny adres nastąpi dopiero w czasie wykonania programu (run-time). Kompilator "wie" zatem tylko tyle: p->Oddychaj()(); //która wersja Oddychaj() ??? Aby mieć pewność, co w tym momencie będzie wskazywał pointer, kompilator musiałby wiedzieć w jaki sposób będzie przebiegać wykonanie programu. Takie wyrażenie może zostać wykonane "w ruchu programu" dwojako: raz, gdy pointer będzie wskazywał Ciapka (inaczej), a drugi raz - Sardynkę (inaczej): CZwierzak *p; ... for(p = &Ciapek, int i = 0; i < 2; i++) { p->Oddychaj(); p = &Sardynka; } lub inaczej: if(p == &Ciapek) CZwierzak::Oddychaj(); else CRybka::Oddychaj(); Taki efekt nazywa się polimorfizmem uruchomieniowym (ang. run-time polymorphism). Overloading funkcji i operatorów daje efekt tzw. polimorfizmu kompilacji (ang. compile-time), to funkcje wirtualne dają efekt polimorfizmu uruchomieniowego (run-time). Ponieważ wszystkie wersje funkcji wirtualnej mają taki sam prototyp, nie ma innej metody stwierdzenia, którą wersję funkcji należy zastosować. Wybór właściwej wersji funkcji może być dokonany tylko na podstawie typu obiektu, do którego należy wersja funkcji-metody. Różnica pomiędzy polimorfizmem przejawiającym się na etapie kompilacji i poliformizmem przejawiającym się na etapie uruchomienia programu jest nazywana również wszesnym albo póżnym polimorfizmem (ang. early/late binding). W przypadku wystąpienia wczesnego polimorfizmu (compile-time, early binding) C++ wybiera wersję funkcji (poddanej overloadingowi) do zastosowania już tworząc plik .OBJ. W przypadku późnego polimorfizmu (run-time, late binding) C++ wybiera wersję funkcji (poddanej przesłanianiu - overriding) do zastosowania po sprawdzeniu bieżącego kontekstu i zgodnie z bieżącym wskazaniem pointera. Przyjrzyjmy się dokładniej zastosowaniu wskaźników do obiektów w przykładowym programie. Utworzymy hierarchię złożoną z klasy bazowej i pochodnej w taki sposób, by klasa pochodna zawierała jakiś unikalny element - np. nie występującą w klasie bazowej funkcję. class CZwierzak { public: void Jedz(); virtual void Oddychaj() {cout << "\nSapie...";} }; class CPiesek : public CZwierzak { char imie[20]; void Szczekaj() { cout << "Szczekam !!!"; } } Ciapek; Jeśli teraz zadeklarujemy wskaźnik do obiektów klasy bazowej: CZwierzak *p; to przy pomocy tego wskaźnika możemy odwołać się także do obiektów klasy pochodnej oraz do elementów obiektu klasy pochodnej - np. do funkcji p->Oddychaj(). Ale pojawia się tu pewien problem. Jeśli zechcelibyśmy wskazać przy pomocy pointera taki element klasy pochodnej, który nie został odziedziczony i którego nie ma w klasie bazowej? Rozwiązanie jest proste - wystarczy zarządać od C++, by chwilowo zmienił typ wskaźnika z obiektów klasy bazowej na obiekty klasy pochodnej. W przypadku funkcji Szczekaj() w naszym programie wyglądałoby to tak: CZwierzak *p; ... p->Oddychaj(); p->Szczekaj(); //ŹLE ! (CPiesek*)p->Szczekaj(); //Poprawnie ... Dzięki funkcjom wirtualnym tworząc klasy bazowe pozwalamy późniejszym użytkownikom na rozbudowę funkcji-metod w najwłaściwszy ich zdaniem sposób. Dzięki tej "nieokreśloności" dziedzicząc możemy przejmować z klasy bazowej tylko to, co nam odpowiada. Funkcje w C++ mogą być jeszcze bardziej "nieokreślone" i rozbudowywalne. Nazywają się wtedy funkcjami w pełni wirtualnymi.