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:
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:
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.
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:
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).