Na tych zajęciach zaznajomimy się z dziedziczeniem oraz dynamicznym (wirtualnym) polimorfizmem. Są to podstawowe narzędzia programowania obiektowego, którym zawdzięcza ono w dużej mierze swoją popularność. Pozwalają one na pisanie przejrzystego kodu i konstruowanie łatwych w użyciu interfejsów. Dodajmy jednak, że współcześnie w C++ odchodzi się od tych metod na rzecz statycznych (tzn. rozstrzyganych w czasie kompilacji) rozwiązań. Powodem tej zmiany jest ich koszt w wydajności programu. Warto je jednak znać, szczególnie, że w wielu przypadkach czytelność kodu może być ważniejsza od kilku nanosekund czasu wykonania.
Dziedziczenie jest prostym, ale bardzo potężnym konceptem. Sprowadza się do następującej zasady: jeżeli klasa D
dziedziczy po klasie B
, to D
otrzymuje “w spadku” funkcjonalność (pola i metody) B
, którą może dodatkowo rozszerzyć. Mówiąc ściślej, obiekt typu D
zawiera w sobie podobiekt klasy B
. Pozwala nam to na uproszczenie struktury kodu poprzez komponowanie nowych klas z już istniejących. Na przykład, jeżeli mamy klasy Ołówek
i Gumka
, to chcąc zaimplementować klasę reprezentującą ołówek z gumką na końcu, możemy po prostu stworzyć pustą klasę, która dziedziczy po Ołówek
i Gumka
.
class Ołówek { /* ... */ };
class Gumka { /* ... */ };
class OłówekZGumką : public Ołówek, public Gumka {};
Tego typu strategię możemy znaleźć w bibliotece I/O. Klasy strumieni dziedziczą po odpowiednich klasach bazowych w zależności od tego czy mają być strumieniami wejścia, wyjścia, czy obu.
Inne możliwe zastosowanie dziedziczenia polega na użyciu klasy bazowej jako wspólnego interfejsu dla wielu klas pochodnych. Zobaczmy, jak może to wyglądać w praktyce.
Napisz klasę Figura
, która posiada prywatną zmienną double pole
.
Napisz setter i getter dla pola tej klasy.
Napisz klasy Kolo
i Kwadrat
, które dziedziczą po Figura
.
Dodaj do napisanych klas konstruktory, które liczą pole figury na podstawie podanych wymiarów geometrycznych.
Dzięki dziedziczeniu po klasie Figura
omijamy wielokrotne definiowanie pola pole
oraz settera i gettera. Nie trudno wyobrazić sobie, że dla większych klas taki zabieg pozwala zaoszczędzić wiele linijek kodu.
W przykładzie powyżej, w konstruktorach klas pochodnych zmuszeni byliśmy używać settera pola klasy bazowej, gdyż było ono prywatne. Wygodniej nam jednak operować bezpośrednio na zmiennej. Z drugiej strony, nie chcemy wystawiać tej zmiennej bezpośrednio do użytkownika. Rozwiązaniem tego dylematu jest trzeci (i ostatni) specyfikator dostępu w C++: protected
(chroniony). Pola chronione mogą być czytane i modyfikowane przez metody klas, które dziedziczą po klasie, do której chronione pole należy, ale nie przez żadne inne.
Uczyń pole pole
chronionym. Usuń setter tego pola i zmodyfikuj odpowiednio konstruktory klas Kwadrat
i Kolo
.
Dodatkowo możemy przy dziedziczeniu zmodyfikować dostęp do pól i metod klasy bazowej za pomocą modyfikatorów dostępu. Nie będziemy tego ćwiczyć, ale dla kompletności bieżącego tekstu podajemy niżej wygodną “ściągawkę”.
Modyfikator dostępu | Dostęp w klasie bazowej | Dostęp w klasie pochodnej |
---|---|---|
public |
||
public |
public |
|
protected |
protected |
|
private |
brak dostępu | |
protected |
||
public |
protected |
|
protected |
protected |
|
private |
brak dostępu | |
private |
||
public |
private |
|
protected |
private |
|
private |
brak dostępu |
Fakt, że klasa pochodna zawiera w sobie podobiekt klasy bazowej ma konsekwencje dla sposobu jej konstrukcji. Przypomnijmy, że dla niedziedziczących klas tworzenie obiektu przebiego wg. następującego schematu:
Zobaczmy, co stanie się, gdy spróbujemy bezpośrednio przenieść ten schemat na klasy dziedziczące.
Zmodyfikuj konstruktory klas Kolo
i Kwadrat
tak, aby inicjalizowały pole pole
w swojej liście inicjalizacyjnej. Czy kod się skompiluje?
Błąd kompilacji w powyższym zadaniu wynika z tego, że - z punktu widzenia konstrukcji - klasa bazowa zachowuje się jak pole klasy pochodnej. Ogólny schemat tworzenia obiektu wygląda więc nieco inaczej, niż ten przedstawiony powyżej:
W kodzie wygląda to następująco:
class B1 { /* ... */ };
class B2 { /* ... */ };
// ...
class D : public B1, public B2 // ...
{/* ... */) : B1{/* ... */}, B2{/* ... */} // ...
D(
{// ...
} };
Zmodyfikuj konstruktory klas Kolo
i Kwadrat
tak, aby inicjalizowały odpowiednio obiekt bazowy. Zauważ, że możesz teraz uczynić pole pole
prywatnym, gdyż odwołujesz się nie bezpośrednio do niego, tylko do konstruktora Figura
.
Dotychczas mówiliśmy o dziedziczeniu jako sposobie uproszczenia kodu - omijaliśmy wielokrotne definiowanie tych samych metod i pól w klasach pochodnych. Hierarchie dziedziczenia dają nam jednak także możliwość polimorficznego myślenia, tzn. myślenia o obiekcie w kontekście nie tylko jego typu, ale także typów po których dziedziczy (lub które dziedziczą po nim, ale o tym w dalszej części instrukcji).
Dodaj do wszystkich 3 stworzonych dotychczas klas metodę void id()
, która drukuje informację o typie figury oraz jej polu.
Napisz wolnostojącą funkcję void id(const Figura&)
, która przyjmuje obiekt typu Figura
i woła jego metodę id
. Stwórz obiekt typu Kwadrat
. Czy możesz podać go jako argument do funkcji id
? Jeżeli tak, jaka wiadomość zostanie wydrukowana?
Jak widzimy, wszędzie, gdzie spodziewamy się obiektu klasy bazowej, możemy podać także obiekt klasy pochodnej. Dzieje się tak dzięki niejawnemu rzutowaniu w górę hierarchii dziedziczenia (tzn. w stronę klasy bazowej), które wykonuje za nas kompilator. Rzutowanie takie możemy także wykonać jawnie, za pomocą konwersji static_cast
. Wygląda ona następująco:
Pochodna p;static_cast< Baza >( p ); Baza b =
Rzutować w ten sposób możemy także wskaźniki oraz referencje.
Stwórz obiekt typu Kwadrat
. Zawołaj jego metodę id
. Następnie zawołaj metodę id
jego rzutu na typ Figura
. Czy wołana jest ta sama metoda?
Jak sama nazwa wskazuje, w statycznym polimorfizmie typy, przy pomocy których interpretujemy obiekty, muszą być znane w czasie kompilacji. Powiemy teraz jak decydować o typach obiektów w czasie wykonania programu. Językiem, który do tego wykorzystamy będą wskaźniki i referencje. Jeżeli nie będzie do końca jasne, czemu tak postępujemy, zachęcamy czytelnika do zawieszenia swojego sceptycyzmu do kolejnego rozdziału, w którym pokażemy praktyczne aplikacje przedstawionej tutaj metodologii i wszelkie wątpliwości zostaną rozwiane (a przynajmniej taka jest nadzieja autorów).
Jak widzieliśmy w zadaniach 9. i 10., gdy patrzyliśmy na obiekt przez pryzmat typu, po którym dziedziczy (tzn. uzyskując do niego dostęp przez referencję lub wskaźnik typu bazowego), nie mieliśmy możliwości dostania się do pól i metod tego obiektu pochodzących z jego faktycznej klasy. Jest to zupełnie logiczne - wolnostojącą funkcję id
moglibyśmy napisać oraz skompilować zanim w ogóle rozpoczęlibyśmy pracę nad Kwadrat
i Kolo
(oczywiście zakładając odpowiedni podział na pliki źródłowe i nagłówkowe). id(const Figura&)
zawoła wtedy metodę id
klasy Figura
- żadna inna klasa jeszcze nie istnieje. Naszym celem w tym rozdziale jest przedstawienie sposobu, który pozwoliłby oddalić decyzję o metodzie, która zostanie zawołana do momentu wykonania programu. Możemy wtedy zawołać metodę nie klasy Figura
, ale klasy obiektu, do którego referencja const Figura&
tak naprawdę się odnosi. Mechanizmem w C++, który do tego służy, jest słowo kluczowe virtual
. Klasę, która posiada choć jedną metodę oznaczoną tym słowem nazywamy klasą polimorficzną. W reprezentacji obiektu takiej klasy, kompilator tworzy dodatkowy wskaźnik (vpointer), który wskazuje na “prawdziwy” typ danego obiektu (a dokładniej mówiąc, wskazuje miejsce, w którym zapisane jest “prawdziwe” zachowanie obiektu, tzw. vtable, o szczegółach można przeczytać np. tutaj).
Oznacz metodę id
klasy Figura
jako wirtualną. Oznacz metody id
klas po niej dziedziczących jako nadpisujące (override
). Powtórz zadanie 9. Czy efekt będzie taki sam jak wcześniej? Czy kod wymagał od Ciebie jakiejkolwiek modyfikacji poza słowami virtual
i override
?
Ponieważ jesteśmy teraz w stanie dynamicznie określić “prawdziwe” typy obiektów, na które wskazują wskaźniki i do których odnoszą się referencje, to możemy polimorficzne klasy rzutować także w dół oraz na boki hierarchii dziedziczenia (dynamiczne rzutowanie w górę hierarchii jest ekwiwalentne statycznemu). Służy do tego konwersja dynamic_cast
. Zwróćmy uwagę, że dynamicznie rzutować możemy jedynie wskaźnik i referencje, ale nie obiekty. Jeżeli obiekt, wskaźnik do którego rzutujemy nie jest “tak naprawdę” typu, na który rzutujemy, to wynikiem rzutowania jest wyzerowany wskaźnik (nullptr
). Przypomnijmy, że wskaźniki są konwertowalne na typ bool
, także dynamic_cast
może być np. używany jako mechanizm sprawdzenia, czy wskaźnik klasy bazowej “tak naprawdę” wskazuje na obiekt zadanej klasy pochodnej.
Stwórz dynamicznie obiekt typu Kwadrat
i przypisz go do wskaźnika typu Figura*
(Figura* f = new Kwadrat{/* ... */};
). Jaki będzie wynik dynamicznego rzutowania na f
na Kwadrat*
, a jaki na Kolo*
?
W klasach polimorficznych zawsze powinniśmy oznaczać destruktory jako wirtualne. Jeżeli tego nie zrobimy, a obiekt typu pochodnego zostanie usunięty (w znaczeniu delete
) przy pomocy wskaźnika na typ bazowy, wtedy destruktor typu pochodnego nigdy nie zostanie zawołany. Może to doprowadzić do wycieku zasobów i innych nieprzyjemnych sytuacji.
Zdefiniuj dla stworzonych dotychczas klas destruktor, drukujący informację o zniszczeniu obiektu. Zawołaj w funkcji main
new Kwadrat{/* ... */};
Figura* f = delete f;
Które destruktory zostały zawołane? Jak zmieni się sytuacja, gdy uczynisz destruktory wirtualnymi?
Omówione wyżej metody wirtualne to metody, które możemy nadpisać. Metody abstrakcyjne (zwane też czysto wirtualnymi) to metody, które musimy nadpisać. Klasy posiadające takie metody nazywamy abstrakcyjnymi. Nie wolno nam bezpośrednio instancjonować klas abstrakcyjnych, ale możemy oczywiście tworzyć obiekty typów dziedziczących po typach abstrakcyjnych. Metody abstrakcyjne oznaczamy następująco:
class C
{virtual T metoda(/* argumenty */) = 0;
};
Stwórz klasę BytGeometryczny
i zmodyfikuj Figura
tak, aby po niej dziedziczyła. Dodaj do BytGeometryczny
abstrakcyjną metodę void id()
. Co stanie się, gdy spróbujesz stworzyć obiekt typu BytGeometryczny
? Co stanie się, gdy wykomentujesz z klasy Figura
metodę id
?
Metody abstrakcyjne mogą być np. używane przez autorów bibliotek do zdefiniowania schematu, wg. którego ma być używana dana funkcjonalność, a następnie wymuszenia na użytkowniku dostarczenia własnej implementacji tejże funkcjonalności.
Przećwiczmy teraz kilka wzorców, które występują często w praktyce programistycznej.
Fakt, że możemy wskazywać na obiekty klasy pochodnej wskaźnikiem do klasy bazowej, może być przez nas wykorzystany do trzymania obiektów różnego typu w jednym miejscu.
Napisz klasę WektorFigur
, która przechowuje tablicę wskaźników do obiektów typu Figura
. Dla uproszczenia możesz zaalokować pamięć na wskaźniki statycznie (np. 1000 elementów). Dodaj także do klasy licznik, który śledzi, ile obiektów zostało już przypisane.
Przeciąż dla klasy WektorFigur
operator []
tak, aby móc indeksować się po trzymanych wskaźnikach. Jeżeli indeks przekroczy liczbę trzymanych obecnie elementów, zwróć nullptr
.
Dodaj do klasy WektorFigur
destruktor, który zawoła dla każdego trzymanego obiektu operator delete
.
Napisz metodę push
, która dopisuje na końcu trzymanego zakresu podany wskaźnik i inkrementuje licznik.
Napisz metodę pop
, która niszczy ostatni element trzymanego zakresu i dekrementuje licznik.
W napisanej właśnie klasie WektorFigur
możemy trzymać różnego typu figury. Zauważmy, że jeżeli dopisalibyśmy nową klasę dziedziczącą po Figura
, np. trójkąt, nie musimy zmieniać w WektorFigur
ani jednej linijki. Nasz kod jest zgodny z zasadą open for extension, closed for modification.
Fabryka to klasa, której obiekty są używane do tworzenia innych obiektów.
Napisz klasę FabrykaFigur
i przeciąż dla niej operator ()
tak, aby jego sygnaturą było Figura* operator()(const std::string&, double)
. Jeżeli podanym stringiem jest “kwadrat”, niech operator nawiasów konstruuje przy pomocy podanej liczby kwadrat, a jeżeli podano “kolo”, niech konstruuje koło. W innym przypadku zwróć nullptr
.
W taki sposób możemy decydować o typie tworzonych obiektów dopiero w czasie wykonania programu. Nic nie stoi na przeszkodzie, aby argument podawany do fabryki figur był np. wczytywany z klawiatury lub pobierany z sieci.
Wizytator stanowi nieco bardziej zaawansowany przykład zastosowania polimorfizmu. Poznanie go nie jest niezbędne do kontynuowania bieżącego kursu. Czytelnik powinien w pierwszej kolejności skupić się na solidnym zrozumieniu treści zawartej wyżej. Niemniej jednak, zagadnienie iteracji po elementach różnych typów pojawia się powszechnie i, zdaniem autorów, warto wiedzieć po jakie rozwiązania sięgnąć, gdy taki problem się napotka. Więcej informacji nt. wizytatora dostępne jest np. w artykule na wikipedii, napisanym w dość zrozumiały sposób.
Wizytator to jeden z częściej stosowanych schematów projektowych (ang. design pattern) w C++. Mając dany wskaźnik do obiektu polimorficznego, chcielibyśmy potrafić wykonać na nim różne operacje w zależności od typu obiektu, na który “tak naprawdę” wskazuje. Mając dany kontener takich wskaźników, chcielibyśmy móc się po nim odpowiednio przeiterować. Zacznijmy od prostych rzeczy.
Dodaj to klasy WektorFigur
metodę void idWszystkie()
, która woła po kolei metodę id
trzymanych figur. Pamiętaj, że mając wskaźnik do obiektu, możesz zawołać jego metodę przy użyciu operatora ->
.
W powyższym zadaniu zakładamy, że chcemy zawołać dla zawartości kontenera konkretą metodę. Jest to dość mało ogólne rozwiązanie. Postawmy się w sytuacji, w której piszemy bibliotekę, z której korzystać będą osoby trzecie. Nie możemy do końca przewidzieć, co będą one chciały zrobić z trzymanymi w kontenerze figurami. Musimy zatem przygotować mechanizm, za pomocą którego użytkownik może zdefiniować własną funkcję, która wykona na obiektach operacje zależne od typu obiektu. Rozwiązaniem tego problemu jest właśnie wizytator.
Napisz klasę WizytatorFigurBaza
. Dodaj do niej 2 abstrakcyjne przeciążenia metody void wizytuj(...)
: jedno przyjmujące typ Kwadrat&
i jedno przyjmujące typ Kolo&
.
Dodaj do klasy Figura
abstrakcyjną metodę void akceptuj(WizytatorFigurBaza&)
.
Nadpisz w klasach Kwadrat
i Kolo
metodę akceptuj
tak, aby wołała metodę wizytuj
podanego wizytatora na danym obiekcie geometrycznym (v.wizytuj(*this);
).
Dodaj do klasy WektorFigur
metodę void wizytujWszystkie(WizytatorFigurBaza&)
, która woła po kolei dla każdej trzymanej figury metodę akceptuj
na podanym wizytatorze.
Jako autorzy biblioteki, zakończyliśmy właśnie pracę. Ostatni krok leży po stronie użytkownika.
Napisz klasę WizytatorDrukujacy
, która dziedziczy po klasie WizytatorFigurBaza
. Nadpisz oba przeciążenia metody wizytuj
tak, aby drukowały informację o typie wizytowanej figury.
Właśnie w pełni zaimplementowaliśmy wizytator! Aby przetestować jego działanie, wystarczy stworzyć wizytator drukujący oraz kontener figur, wypełnić kontener, a następnie zawołać metodę wizytujWszystkie
na zdefiniowanym wizytatorze. Zastanówmy się, jak dokładnie działa napisany przez nas kod. Zacznijmy od tego, że podany przez nas wizytator jest z punktu widzenia wizytujWszystkie
referencją do bazowej klasy wizytatora. Fakt ten pozwala nam ujednolicić interfejs kontenera - jeżeli chcemy zdefiniować nowy wizytator, który robi coś zupełnie innego, wystarczy, że powtórzymy zadanie 26. Nie musimy modyfikować żadnych napisanych wcześniej klas! Ponownie zachowujemy się zgodnie z zasadą open-closed. Prześledźmy teraz co dokładnie dzieje się przy wizytowaniu każdej figury. Najpierw wołana jest metoda akceptuj
figury. Jest ona wirtualna, a zatem - pomimo tego, że nigdzie jawnie nie trzymamy informacji o “prawdziwym” typie figury - wołana jest metoda odpowiedniej klasy pochodnej. Następnie, metoda akceptuj
klasy pochodnej (Kwadrat
lub Kolo
), woła “na sobie” metodę wizytuj
wizytatora, która przeciążona jest zarówno dla Kwadrat
jak i dla Kolo
. Dość charakterystyczna jest tutaj “podwójna delegacja” (ang. double dispatch) - wizytator wizytuje, ale także obiekt wizytowany akceptuje. Osiągnęliśmy zatem to, co chcieliśmy - możemy dokonywać na trzymanych figurach dowolnie zdefiniowanych operacji zależnych od “prawdziwego” typu figury.
Przetestuj działanie napisanego kodu. Jeżeli masz wątpliwości, że jest ono w pełni dynamiczne możesz uzależnić typ wkładanych do kontenera figur od wejścia z konsoli (pomocna będzie do tego fabryka).