Kontakt  |  PRS 170  |  ALK 420  |  PZR 420  |  SIK 420  |  JPR 222  |  SOP 121


Języki programowania - ćwiczenia 4


Temat zajęć: Programowanie obiektowe w C++: klasy i obiekty, konstruktory i destruktory, dziedziczenie, metody wirtualne.

Literatura:
  • B. Kernighan, D. Ritchie: "Język C"
  • B. Stroustrup: "Język C++"
  • V. Sthern: "C++ Inżynieria Programowania"
Materiały dodatkowe:
  • Opis standardowej biblioteki C (wersja on-line)  >> 

 Pojęcie klasy i obiektu.


Klasę należy traktować jako pewien wzorzec (albo szablon ale proszę nie mylić tego z pojęciem szablonu - template - klasy czy szablonu funkcji), służący do tworzenia obiektów (instancji), które są już fizycznymi bytami w pamięci operacyjnej komputera. Klasa jako taka również istnieje w pamięci komputera w sensie kodu wykonywalnego metod klasy (i pól statycznych, wspólnych dla wszystkich obiektów danej klasy). Klasy (i obiekty) zwykle łączą w sobie dane oraz funkcje (metody), które umożliwiają do nich dostęp i które na tych danych operują. Dane (atrybuty) obiektu definiują jego stan, zaś metody reprezentują operacje, które na danym obiekcie (na jego danych) można wykonać. Metody tworzą więc swego rodzaju interfejs między współpracującymi obiektami tworzącymi program (między obiektem danej klasy a resztą programu).
Klasę można więc w pewnym sensie utożsamiać z typem danych, a obiekty danej klasy ze zmiennymi tego typu.


 Przykład 1

Podstawowe konstrukcje i składnię dotyczącą definiowania klas, tworzenia obiektów i odwoływania się do ich składowych (przez składowe rozumiemy pola i metody) obejrzymy sobie na przykładzie klasy reprezentującej łańcuchy tekstowe (będziemy tą klasę rozwijać w kolejnych przykładach).

Projekt Visual Studio  pobierz  

Plik Lancuch.h  pobierz  
#include <stdio.h>
#include <string.h>

class Lancuch {

private: // tylko metody tej klasy
// mają dostęp do tych elementów
char *nazwa_klasy;

protected: // mają dostęp metody tej
// klasy i klas potomnych
char *znaki;
int dlugosc;

public: // wszyscy mają dostęp do tych elementów

Lancuch(): // konstruktor bezparametrowy -
// definicja w ciele klasy
znaki(NULL),
dlugosc(0) // lista inicjująca
{
printf("Konstruktor bezparametrowy\n");
}

Lancuch(const char *str); // inny konstruktor - deklaracja

~Lancuch(); // destruktor

int podaj_dlugosc(); //
char* podaj_lancuch(); // deklaracje metod
char podaj_znak(int ktory); //

}; // średnik jest niezbędny !!!

W pliku nagłówkowym definiujemy klasę Lancuch. Ogólna postać definicji klasy jest następująca:
class Nazwa [ : klasy_bazowe ] {
private:
składowe prywatne
protected:
składowe chronione
public:
składowe publiczne
};
Klasy bazowe i dziedziczenie omówimy na następnych zajęciach. Składowe prywatne są dostępne tylko z kodu metod klasy, w której występują. Składowe chronione są dostępne w kodzie metod klasy, w której występują oraz w kodzie metod jej klas potomnych (dziedziczących z danej klasy). Składowe publiczne są dostępne z kodu dowolnej funkcji czy metody (zatem poza kodem należącym do danej klasy "widoczne" są tylko składowe publiczne). Przypomnijmy raz jeszcze, że przez składowe rozumiemy pola (atrybuty) i meody.
Każda klasa może posiadać wyróżnione metody: konstruktory i destruktor. Konstruktor (który, to zależy od podanych przy tworzeniu obiektu parametrów) uruchamiany jest automatycznie w momencie tworzenia nowego obiektu danej klasy. Jego nazwa musi być identyczna z nazwą klasy. Z założenia nie zwraca on żadnego wyniku, przy czym pomijamy nawet słowo void oznaczające funkcję bezparametrową. Destruktor uruchomiony zostanie automatycznie w momencie niszczenia obiektu (czy to przez delete, jeśli był on tworzony przez new, czy też przez proste wyjście z zasięgu widoczności zmiennej, jeśli obiekt był zmienną automatyczną). Destruktor również jest metodą bezwynikową, o nazwie równej nazwie klasy poprzedzonej znakiem ~ (tylda). Nie może przyjmować parametrów. O ile konstruktorów może być wiele (mogą różnić się listą parametrów), destruktor zawsze jest dokładnie jeden (jeśli programista nie poda jego implementacji, kompilator automatycznie wygeneruje pusty destruktor).
Ciała (kod) metod możemy podać albo wewnątrz definicji klasy, albo na zewnątrz tej definicji. W przykładzie powyżej konstruktor bezparametrowy zaimplementowany jest wewnątrz definicji klasy, natomiast pozostałe metody (łącznie z destruktorem i drugim konstruktorem) zaimplementowane będą w module Lancuch.cpp, natomiast w ciele klasy znajdują się jedynie ich deklaracje.


Plik Lancuch.cpp  pobierz  
#include "Lancuch.h"

Lancuch::Lancuch(const char *str) {
znaki = strdup(str);
dlugosc = (int) strlen(str);
printf("Konstruktor z const char* - parametr %s\n",str);
}

Lancuch::~Lancuch() {
printf("Destruktor - usuwany %s\n",podaj_lancuch());
if (znaki) delete[] znaki;
}

int Lancuch::podaj_dlugosc() {
return dlugosc;
}

char* Lancuch::podaj_lancuch() {
if (znaki) return znaki;
else return "\0";
}

char Lancuch::podaj_znak(int ktory) {
if (ktory>=dlugosc) return '\0';
else return znaki[ktory];
}
Ten plik źródłowy zawiera implementację metod zadeklarowanych w klasie Lancuch. Proszę zauważyć, że aby plik został poprawnie skompilowany, musimy dołączyć do niego definicję samej klasy Lancuch (przez dołączenie pliku nagłówkowego Lancuch.h). Jeśli implementujemy metodę klasy poza ciałem tej klasy musimy oznaczyć ją jako należącą do danej klasy za pomocą operatora przynależności :: (podwójny dwukropek).
Jeśli wewnątrz ciała metody odwołamy się do pola klasy (poprzez jego nazwę, np. znaki czy dlugosc w przykładzie powyżej), to odwołanie to dotyczy bieżącego obiektu, tzn. obiektu, z którego została wywołana dana metoda (za wyjątkiem pól statycznych, o których będziemy mówić później). Przykład:
Lancuch l1("ABC"), l2; // dlugosc w l1 = 3, dlugosc w l2 = 0
printf("%d, %d", l1.podaj_dlugosc(), l2.podaj_dlugosc()); // wynik: 3, 0
W powyższym fragmencie w obu przypadkach (i w l1.podaj_dlugosc() i w l2.podaj_dlugosc()) wywołana zostanie metoda Lancuch::podaj_dlugosc() (raz na rzecz obiektu l1, raz l2)., która zwraca wartość pola dlugosc. Jednak w jednym przypadku będzie to wartość pola dlugosc z obiektu l1, a w drugim - wartość pola dlugosc z obiektu l2.
Reasumując, obiekty klasy zawsze mają taki zestaw pól, jaki definiuje klasa, do której należą, jednak wartości tych pól przechowywane są niezależnie wewnątrz każdego obiektu.


Plik TestLancuch.cpp  pobierz  
#include "Lancuch.h"

int main() {
Lancuch l1;
// wywołany konstruktor bezparametrowy

Lancuch l2("TOMEK");
// wywołany konstruktor z const char*

Lancuch *l3 = new Lancuch("ABCDE");
// również konstruktor z const char*

printf("%s, %s, %s\n",l1.podaj_lancuch(), l2.podaj_lancuch(),
l3->podaj_lancuch());

delete l3; // destruktor z l3


return 0;
}
Powyższy kod pokazuje sposoby tworzenia obiektów i odwołania się do ich składowych. Podobnie jak w przypadku struktur języka C, również dla wkaźników na obiekty w C++ dostępny jest operator ->.
Zwróćmy jeszcze uwagę na wywołania destruktorów. W przypadku obiektu l3 oczywiste jest, że wywołanie destruktora dla tego obiektu nastąpi w momencie wykonania delete l3. A co z pozostałymi obiektami?

 Zadanie 1

Sprawdzić w którym momencie (i czy w ogóle) zostaną uruchomione destruktory dla obiektów l1 i l2 w przykładzie 1.


 Dziedziczenie

Klasy mogą dziedziczyć pola i metody (nieprywatne) z innych klas, tworząc w ten sposób tzw. hierarchię klas, czyli klasy potomne w naturalny sposób rozszerzają funkcjonalność klas bazowych, zachowując przy tym wszelkie (nieprywatne) właściwości tych ostatnich.
Każde pole i każda metoda, które nie znajdują się w sekcji private danej klasy są również dostępne w klasach potomnych, zatem każda klasa potomna jest w sensie typu zgodna ze swoją klasą bazową (ale nie na odwrót!).
Zgodność ta ma swoje "logiczne" uzasadnienie. Załóżmy, że klasa B rozszerza funkcjonalność klasy A (czyli B dziedziczy z A). Wyobraźmy sobie funkcję f, która jako parametr formalny przyjmuje obiekt klasy A. W takim przypadku możemy bezpiecznie jako parametr aktualny (w momencie wywołania funkcji f) podać obiekt klasy B, ponieważ:
  • wszystkie atrybuty i metody publiczne, które dostępne są w A, dostępne są również w B,
  • f i tak nie ma dostępu do składowych prywatnych w A, zatem fakt, że składowe te nie są dziedziczone przez B nie ma znaczenia z punktu widzenia f.
Oczywiście zgodność nie zachodzi w drugą stronę, ponieważ jeśli funkcja g wymaga jako parametru obiektu klasy B, to nie możemy podać w jego miejsce obiektu klasy A (nadal zakładamy, że B rozszerza A), ponieważ w klasie tej może brakować składowych, które występują wyłącznie w B (są rozszerzeniem funkcjonalności B w stosunku do A).

 Przykład 2

Solution Visual Studio  pobierz  

Plik ZmiennyLancuch.cpp  pobierz  
#include <stdio.h>
#include <string.h>

class Lancuch {

private:
char *nazwa_klasy; // niedostepne w ZmiennyLancuch

protected:
char *znaki;
int dlugosc;

public:

Lancuch():
znaki(NULL),
dlugosc(0) // lista inicjująca
{
printf("Konstruktor Lancuch bezparametrowy\n");
}

Lancuch(const char *str); // inny konstruktor - deklaracja

~Lancuch(); // destruktor

int podaj_dlugosc(); //
char* podaj_lancuch(); // deklaracje metod
char podaj_znak(int ktory); //

}; // średnik jest niezbędny !!!

class ZmiennyLancuch: public Lancuch
{
public:
ZmiennyLancuch() {
printf("Konstruktor ZmiennyLancuch bezparametrowy\n");
}
ZmiennyLancuch(const char *str): // przykrywamy konstruktor
// z Lancuch
Lancuch(str) // konstruktor bazowy
{
printf("Konstruktor ZmiennyLancuch z const char*\n");
}
~ZmiennyLancuch() {
printf("Destruktor ZmiennyLancuch\n");
}

// nowa metoda, rozszerzająca
// funkcjonalność Lancuch
void ustal_lancuch(const char *str);

};

// implementacje metod
Lancuch::Lancuch(const char *str) {
znaki = strdup(str);
dlugosc = strlen(str);
printf("Konstruktor Lancuch z const char*\n");
}

Lancuch::~Lancuch() {
if (znaki) delete znaki;
printf("Destruktor Lancuch\n");
}

int Lancuch::podaj_dlugosc() {
return dlugosc;
}

char* Lancuch::podaj_lancuch() {
if (znaki) return znaki;
else return "\0";
}

char Lancuch::podaj_znak(int ktory) {
if (ktory>=dlugosc) return '\0';
else return znaki[ktory];
}

void ZmiennyLancuch::ustal_lancuch(const char *str) {
if (znaki) delete znaki;
znaki = strdup(str);
dlugosc = strlen(str);
}

int main() {
ZmiennyLancuch *l1 = new ZmiennyLancuch();
ZmiennyLancuch *l2 = new ZmiennyLancuch("TOMEK");
Lancuch *l3 = new ZmiennyLancuch("ZMIENNY CZY NIE?");

l1->ustal_lancuch("ABCDE");

// nie da sie bezposrednio l3->ustal_lancuch() bo
// formalnie l3 jest klasy Lancuch, ale mozna tak:
((ZmiennyLancuch*) l3)->ustal_lancuch("EFG");

printf("%s, %s, %s\n",l1->podaj_lancuch(),
l2->podaj_lancuch(),
l3->podaj_lancuch()
);
// można wywołać podaj_lancuch() z obiektu klasy ZmiennyLancuch
// ponieważ ZmiennyLancuch dziedziczy wszystkie nieprywatne metody
// i pola z Lancuch, w tym także podaj_lancuch()

delete l1;
delete l2;
delete l3;

return 0;

}
Po skompilowaniu i uruchomieniu przykładu powinniśmy dostać następujące wyjście (z dokładnością do numerów wierszy, które dodałem ręcznie):
 1: Konstruktor Lancuch bezparametrowy
 2: Konstruktor ZmiennyLancuch bezparametrowy
 3: Konstruktor Lancuch z const char*
 4: Konstruktor ZmiennyLancuch z const char*
 5: Konstruktor Lancuch z const char*
 6: Konstruktor ZmiennyLancuch z const char*
 7: ABCDE, TOMEK, EFG
 8: Destruktor ZmiennyLancuch
 9: Destruktor Lancuch
10: Destruktor ZmiennyLancuch
11: Destruktor Lancuch
12: Destruktor Lancuch

Przyjrzyjmy się konstruktorom i destruktorom (wywołania innych metod są w miarę przejrzyste). Jako pierwszy tworzony jest obiekt l1 klasy ZmiennyLancuch. Wynik działania jego konstruktora widzimy w wierszu 2, jednak co robi konstruktor klasy Lancuch w wierszu 1? Przecież nie tworzyliśmy do tej pory żadnego obiektu klasy Lancuch. Otóż w przypadku tworzenia obiektu klasy potomnej, automatycznie wywoływany jest wcześniej konstruktor klasy bazowej (bezparametrowy, o ile konstruktor klasy potomnej nie wywołuje jawnie innego konstruktora klasy bazowej). Jedna zagadka rozwiązana.
Wiersze 3 i 4 to skutek tworzenia obiektu l2 - tutaj sprawa jest bardziej klarowna, ponieważ w konstuktorze ZmiennyLancuch(const char*) jawnie wywołujemy konstruktor klasy bazowej. Analogiczna sytuacja występuje w przypadku obiektu l3 (wiersze 5 i 6).
Wiersze 8 i 9 to skutek zniszczenia obiektu l1 - podobnie jak w przypadku konstruktorów, również destruktor klasy bazowej jest wywoływany automatycznie po destruktorze klasy potomnej. Analogicznie wiersze 10 i 11 to skutek zniszczenia obiektu l2.
Na podstawie dotychczasowych obserwacji możemy wysnuć wniosek, że konstruktor danej klasy powinien zajmować się inicjowaniem wyłącznie tych pól, które zostały dodane w stosunku do klasy bazowej (inicjowanie pól klasy bazowej jest zadaniem konstruktora klasy bazowej - czy to wywoływanego jawnie w konstruktorze klasy potomnej, czy też konstruktora domyślnego wywoływanego niejawnie, jeśli konstruktor klasy potomnej nie zawiera odwołania do konstruktora klasy bazowej).
Analogiczna sytuacja ma miejsce w przypadku destruktorów. Destruktor klasy potomnej powinien niszczyć wyłącznie pola, które zostały dodane w klasie potomnej - zniszczenie np. dynamicznie alokowanego pola klasy bazowej w destruktorze klasy potomnej skutkuje ponownym zniszczeniem tego pola (a przynajmniej próbą zniszczenia - zakończy się ona błędem dostępu do pamięci) w destruktorze klasy bazowej.
W przypadku konstruktorów i destruktorów występuje więc pewien naturalny podział odpowiedzialności - klasa odpowiada za tworzenie i niszczenie tylko tych pól, które zadeklarowane są jawnie w danej klasie (tzn. takich, które nie są dziedziczone z klasy bazowej). W przypadku pól dziedziczonych inicjacja i niszczenie powinny odbywać się za pomocą konstruktora (automatycznie bądź jawnie) i destruktora (automatycznie) klasy bazowej.
Kolejną zagadką jest wiersz 12. Jest to skutek zniszczenia obiektu l3. Ale przecież wiemy, że l3 jest klasy ZmiennyLancuch, gdzie więc podziało się wywołanie jej destruktora? Zauważmy, że obiekt l3 formalnie zadeklarowaliśmy jako obiekt klasy Lancuch, zatem kompilator wywołuje wszelkie metody na rzecz l3 (również destruktor) z klasy Lancuch. Aby aktywacja destruktorów w takim przypadku była poprawna, musimy poznać mechanizm metod wirtualnych (destruktor w takim przypadku musi być wirtualny - wówczas program zachowa się zgodnie z naszymi oczekiwaniami).


 Kolejność aktywacji konstruktorów i destruktorów

W zasadzie problem wyjaśniliśmy analizując przykład 2, jednak powtórzymy argumentację ponownie, być może w nieco bardziej uporządkowanej formie.
Aby poprawnie korzystać z technologii programowania obiektowego w języku C++ należy mieć świadomość, że z każdym utworzeniem lub usunięciem obiektu związane jest wywołanie specjalnej metody składowej klasy: w przypadku tworzenia obiektu jest to konstruktor, w przypadku niszczenia obiektu - destruktor.
W przypadku pojedynczych (samodzielnych) klas (nie dziedziczących z innych klas), sytuacja wydaje się oczywista. Po alokacji pamięci dla obiektu uruchamiany jest jeden z konstruktorów, dopasowywany na podstawie parametrów podanych w kodzie tworzącym obiekt. Tuż przed usunięciem obiektu z pamięci uruchamiany jest destruktor, który jest tylko jeden dla danej klasy.
Co jednak dzieje się w przypadku, gdy dana klasa dziedziczy z innej klasy (która być może dziedziczy z kolejnej itd.)? Załóżmy, że mamy bazową klasę A, z której dziedziczy klasa B, a z tej z kolei dziedziczy klasa C (jak w przykładzie poniżej). Jakie operacje powinien wykonać konstruktor klasy C? Oczywiście musi on zainicjalizować pola klasy C, których nie ma w B i w A. Czy powinien jednak inicjalizować odziedziczone pola klas B i A? Odpowiedź na to pytanie nie jest jednoznaczna (zależy od okoliczności), możemy spróbować wywnioskować ją z następujących faktów:
  • jedną z podstawowych zasad w języku C++ jest możliwość wielokrotnego wykorzystania kodu, zatem konieczność przepisywania inicjalizacji odziedziczonych pól w konstruktorze klasy potomnej byłaby krokiem w przeciwnym kierunku,
  • klasa bazowa może posiadać pola prywatne i w oczywisty sposób klasa potomna nie jest w stanie ich zainicjalizować.
Wydaje się zatem oczywiste, że (w większości przypadków) konstruktor klasy potomnej powinien skorzystać z konstruktora klasy bazowej do inicjalizacji pól odziedziczonych (konstruktor klasy bazowej może skorzystać z konstruktora swojej klasy bazowej itd.) oraz wykonać samodzielnie inicjalizację pól, które nie zostały odziedziczone (czyli tych, o które została rozszerzona klasa potomna i o których w oczywisty sposób konstruktor klasy bazowej nie ma pojęcia).
Kompilator C++ zapewnia automatyczne wywołanie bezparametrowego konstruktora klasy bazowej, o ile w konstruktorze klasy potomnej nie wywołamy jawnie jakiegoś innego konstruktora. Jeśli konstruktor klasy C nie wywoła jawnie konstruktora klasy B, to i tak konstruktor taki zostanie wywołany (w tym przypadku będzie to konstruktor domyślny - bezparametrowy).
Podobnie jest z destruktorami, z tym że tutaj nie wywołujemy jawnie destruktora klasy bazowej (i tak nie ma z czego wybierać, bo destruktor jest tylko jeden), bo robi to za nas kompilator produkując odpowiedni kod.
Powinniśmy zatem mieć świadomość, że w momencie tworzenia obiektu pewnej klasy wywołane może być wiele metod różnych klas (co najmniej konstruktory wszystkich klas bazowych aż do korzenia hierarchii dziedziczenia). Analogiczna sytuacja ma miejsce w momencie niszczenia obiektów.
Rozważmy następujący przykład:

 Przykład 3

Solution Visual Studio  pobierz  

Plik p2.cpp  pobierz  
#include <iostream>

using namespace std;

int lobj = 0; // licznik obiektów

class A {
protected:
int nr; // numer obiektu
public:
A();
~A();
};

class B: public A {
public:
B();
B(const char *c);
B(int i);
~B();
};

class C: public B {
public:
C();
C(const char *c);
C(int i);
~C();
};

A::A()
{
nr = ++lobj;
cout << "A::A(), obiekt nr " << nr << endl;
}

A::~A()
{
cout << "A::~A(), obiekt nr " << nr << endl;
}

B::B()
{
cout << "B::B(), obiekt nr " << nr << endl;
}

B::B(const char *c)
{
cout << "B::B(" << c << "), obiekt nr " << nr << endl;
}

B::B(int i)
{
cout << "B::B(" << i << "), obiekt nr " << nr << endl;
}

B::~B()
{
cout << "B::~B(), obiekt nr " << nr << endl;
}

C::C()
{
cout << "C::C(), obiekt nr " << nr << endl;
}

C::C(const char *c):
B(c)
{
cout << "C::C(" << c << "), obiekt nr " << nr << endl;
}

C::C(int i)
{
cout << "C::C(" << i << "), obiekt nr " << nr << endl;
}

C::~C()
{
cout << "C::~C(), obiekt nr " << nr << endl;
}

int main()
{
A a; // obiekt nr 1
B b; // obiekt nr 2
C c; // obiekt nr 3
C c2("xxx"); // obiekt nr 4
C *c3 = new C(10); // obiekt nr 5
return 0; // niszczenie obiektów
}
Zanim skompilujemy i uruchomimy przykład, spróbujmy wydedukować jakie konstruktory i destruktory zostaną uruchomione i w jakiej kolejności.
  • obiekt nr 1
    Obiekt jest klasy A, tworzymy go bez podania parametrów, zostanie więc uruchomiony konstruktor bezparametrowy A().
  • obiekt nr 2
    Obiekt jest klasy B, zostanie więc uruchomiony konstruktor B(), lecz najpierw będzie uruchomiony konstruktor A() jako konstruktor klasy bazowej (i to wywołanie tworzone jest automatycznie przez kompilator - proszę sprawdzić konstruktor B() - nie ma tam jawnego wywołania A()).
  • obiekt nr 3
    Zostanie uruchomiony C(), lecz najpierw będzie uruchomiony B() jako konstruktor klasy bazowej. Ale moment, przecież B dziedziczy z A! Zgodnie więc z regułą C++, jako pierwszy będzie uruchomiony konstruktor A(), potem B(), a na końcu C().
  • obiekt nr 4
    Zostanie uruchomiony C(const char*), który jawnie wywołuje B(const char*). Jednak B(const char*) nie wywołuje jawnie żadnego konstruktora klasy A, w związku z czym nastąpi automatyczne wywołanie A().
  • obiekt nr 5
    Zostanie wywołany C(int). Mimo, że istnieje konstruktor B(int), który "na logikę" pasuje do C(int), to jeśli C(int) nie wywoła go jawnie (a w tym przypadku takie wywołanie nie występuje), zostanie uruchomiony B() (zgodnie z ogólną zasadą - jeśli programista nie powiedział czego chce, to kompilator nie próbuje tego zgadnąć), który z kolei powoduje wywołanie A().
  • niszczenie obiektów
    Nie wiadomo w jakiej kolejności obiekty (1,2,3,4) będą niszczone (zwykle w odwrotnej kolejności do ich tworzenia, ale zależy to od kompilatora), wiadomo jedynie, że zostaną zniszczone wszystkie oprócz obiektu nr 5 (czyli obiektu wskazywanego przez wskaźnik c3). Obiekt nr 5 (czyli c3) powinniśmy zniszczyć jawnie, za pomocą operatora delete (wywołując delte c3;). W przykładzie zostało to pominięte, co jest ewidentnym błędem i przyczyną wycieków pamięci (memory leaks). W każdym razie przy niszczeniu obiektu klasy A będzie wywoływany ~A(), przy niszczeniu obiektów klasy B będzie najpierw wywoływany ~B(), potem ~A(), a przy niszczeniu obiektów klasy C będzie najpierw wywoływany ~C(), potem ~B(), a na końcu ~A(). Jest więc oczywiste, że destruktor klasy C nie powinien usuwać z pamięci przydzielonych dynamicznie zasobów odziedziczonych np. z B, ponieważ za chwilę destruktor klasy B spróbuje je usunąć ponownie, co może prowadzić do błędów. W myśl ogólnej zasady, każda klasa powinna inicjalizować i usuwać jedynie właściwe dla siebie składowe, pozostawiając operacje na składowych odziedziczonych odpowiednim metodom klas bazowych (czyli niejako spychamy odpowiedzialność "w górę" na kolejne klasy bazowe). Warto jest trzymać się tej reguły.

Proszę przemyśleć powyższą argumentację (pytając jeśli coś wydaje się niejasne), a następnie skompilować i uruchomić przykład aby sprawdzić czy przewidywania były trafne.


 Metody wirtualne

Rozważmy następujący fragment kodu:
class A {
public:
void m(void)
{
printf("A::m\n");
}
};

class B : public A {
public:
void m(void)
{
printf("B::m\n");
}
};

int main()
{
A *obj = new B();
obj->m();
delete obj;
return 0;
}
Czy na wyjściu programu pojawi się A::m czy B::m? Ponieważ metoda m() jest niewirtualna, w tym przypadku odpowiedź brzmi: A::m (mimo, że faktycznie obiekt obj jest klasy B). Dlaczego? Ponieważ mamy tu do czynienia z tzw. wczesnym wiązaniem (compile-time binding), czyli już w momencie kompilacji następuje powiązanie wywołania obj->m() z metodą A::m (ponieważ formalnie, zgodnie z deklaracją, obj wskazuje na obiekt klasy A).

Co daje zastosowanie metod wirtualnych? Rozważmy modyfikację powyższego kodu:
class A {
public:
virtual void m(void)
{
printf("A::m\n");
}
};

class B : public A {
public:
virtual void m(void)
{
printf("B::m\n");
}
};

int main()
{
A *obj = new B();
obj->m();
delete obj;
return 0;
}
Teraz na wyjściu programu pojawi się B::m. Co zatem się stało? Tym razem mamy do czynienia z tzw. późnym wiązaniem (run-time binding), czyli decyzja o tym, którą implementację (A::m() czy B::m()) uruchomić w instrukcji obj->m(), podejmowana jest w czasie działania programu, a nie już na etapie kompilacji. Z każdym obiektem klasy, która posiada metody wirtualne, związana jest tzw. tablica metod wirtualnych (VMT - virtual method table), która dołączana jest do obiektu w momencie jego tworzenia (wtedy klasa obiektu zawsze jest podawana jawnie, więc wiadomo którą tablicę metod wirtualnych do niego dołączyć). Każda metoda wirtualna wywoływana jest poprzez tą tablicę (można ją sobie w uproszczeniu wyobrazić jako tablicę wskaźników na funkcje). Zatem w naszym przykładzie obiekt obj posiada VMT, w której na pozycji odpowiadającej metodzie m() znajduje się wskaźnik na B::m() (a nie na A::m()), zatem niezależnie od tego, jakiego formalnie typu jest wskaźnik obj, zawsze wywołana zostanie metoda z tej klasy, do której należy wskazywany obiekt (w tym przypadku z klasy B).
Reasumując, metody wirtualne wywoływane są zawsze z faktycznej klasy, do której należy dany obiekt.

Rozważmy poniższy przykład.

 Przykład 4

Solution Visual Studio  pobierz  

Plik p3.cpp  pobierz  
#include <iostream>

using namespace std;

class A {
public:
virtual void metoda();
};

class B: public A {
public:
virtual void metoda();
};

void A::metoda()
{
cout << "metoda z klasy A" << endl;
}

void B::metoda()
{
cout << "metoda z klasy B" << endl;
}

int main()
{
A a;
B b;
A *a2 = new B();
A &a3 = b;
A a4 = b;
a.metoda(); // 1
b.metoda(); // 2
a2->metoda(); // 3
a3.metoda(); // 4
a4.metoda(); // 5
delete a2;
return 0;
}
Pytanie brzmi: które implementacje metody metoda (z klasy A czy B) zostaną wywołane w kolejnych instrukcjach (1,2,3,4,5)? Metoda metoda jest wirtualna, zatem jedyne co musimy wiedzieć, to jakiej naprawdę klasy jest dany obiekt (niezależnie od formalnej definicji referencji czy wskaźnika). Możemy przeprowadzić następujące rozumowanie:
  • instrukcja 1
    Zmienna a jest obiektem klasy A (formalnie i faktycznie), więc oczywiście zostanie uruchomiona A::metoda().
  • instrukcja 2
    Zmienna b jest obiektem klasy B (formalnie i faktycznie) więc zostanie uruchomiona B::metoda().
  • instrukcja 3
    Tu już nic nie jest oczywiste. Mamy wskaźnik typu A*, który wskazuje na obiekt klasy B. Czy to jest prawidłowe? Tak, ponieważ w C++ klasy są zgodne "w dół", czyli klasa potomna jest pod względem typu zgodna z klasą bazową (ale nie na odwrót). Zatem wskaźnik typu A* może wskazywać na obiekt klasy B. Jednak metoda metoda jest wirtualna, zatem w instrukcji 3 zostanie uruchomiona B::metoda(), a nie A::metoda(), ponieważ obiekt wskazywany przez a2 jest faktycznie obiektem klasy B.
  • instrukcja 4
    Analogicznie do instrukcji 2, wszystkie reguły dotyczą także referencji.
  • instrukcja 5
    Tutaj zostanie uruchomiona A::metoda, ponieważ a4 jest tak naprawdę obiektem klasy A. Po prostu został stworzony obiekt klasy A zainicjowany obiektem klasy B za pomocą domyślnego konstruktora kopiującego (zatem a4 jest tak naprawdę innym obiektem niż b).
Proszę skompilować przykład i sprawdzić wyniki.

Kolejny przykład ilustrujący wykorzystanie metod wirtualnych znajduje się poniżej.

 Przykład 5

Solution Visual Studio  pobierz  

Plik p4.cpp  pobierz  
#include <iostream>
#include <string>

using namespace std;

class A {
public:
virtual string podaj_nazwe();
void pisz();
};

class B: public A {
public:
virtual string podaj_nazwe();
};

string A::podaj_nazwe()
{
return string("A");
}

void A::pisz()
{
cout << "jestem obiektem klasy " << podaj_nazwe() << endl;
}

string B::podaj_nazwe()
{
return string("B");
}

int main()
{
A a;
B b;
A *a2 = new B();
a.pisz();
b.pisz();
a2->pisz();
delete a2;
return 0;
}
UWAGA: zastosowany w przykładzie typ string jest klasą (czyli zmienne typu string są obiektami). Jest to jedna z klas biblioteki standardowej C++ (będziemy jeszcze o niej mówić). Klasa string znajduje się w przestrzeni nazw std, zatem formalnie powinniśmy odwoływać się do niej przez nazwę kwalifikowaną std::string, jednak instrukcja using std, znajdująca się na początku modułu, umożliwia nam pomijanie kwalifikatora przestrzeni nazw (std).

Klasa A posiada metodę pisz(), którą dziedziczy z niej klasa B (proszę zauważyć, że nie została ona pokryta w B). Metoda ta wywołuje metodę podaj_nazwe(), która jest wirtualna i posiada inną implementację w klasie A, a inną w B. Jasne jest zatem, że w przypadku A::pisz() wywołanej z obiektu klasy A zostanie wywołana A::podaj_nazwe(). Jednak nie jest to już tak oczywiste w przypadku A::pisz() wywołanej z obiektu klasy B (a nie ma metody B::pisz(), bo nie została ona pokryta w B). Mechanizm zwany polimorfizmem zapewnia, że wszystkie metody wirtualne zostaną wywołane z klas, do których faktycznie należą docelowe obiekty metod. Jeśli zatem metoda A::pisz() korzysta z metody wirtualnej podaj_nazwe(), to w przypadku obiektu klasy A zostanie wywołana metoda A::podaj_nazwe(), a w przypadku obiektu klasy B metoda B::podaj_nazwe() (mimo, że w przypadku obiektu klasy B uruchomiona będzie metoda A::pisz(), bo nie istnieje metoda B::pisz()).
Mechanizm ten działa tylko w przypadku metod wirtualnych, co łatwo sprawdzić. Po skompilowaniu i uruchomieniu powyższego przykładu proszę oznaczyć metodę podaj_nazwe() jako niewirtualną i spróbować uruchomić przykład ponownie. Czy uzyskane wyniki są zgodne z intuicją?
Polimorfizm jest potężnym narzędziem programistycznym i poznamy jeszcze kilka przykładów jego wykorzystania.



Valid HTML 4.01!