Wirtualne funkcje składowe, wirtualne destruktory, czyste funkcje wirtualne, składowe chronione, słowo kluczowe protected.
Sposób wykorzystania polimorfizmu przedstawię na przykładzie implementacji pewnej struktury danych - drzewa arytmetycznego. Pomysł polega na spostrzeżeniu, że dowolne wyrażenie arytmetyczne można zapisać w postaci drzewa, którego węzły-rozgałęzienia odpowiadają operatorom arytmetycznym, a węzły-liście zawierają liczby. Rysunek 2.3 ilustruje przykład drzewa arytmetycznego odpowiadającego wyrażeniu 2 * (3 + 4) + 5. Analizując jego strukturę od korzenia (jak widzimy, drzewka arytmetyczne rosną w odwrotnym kierunku, niż prawdziwe drzewa) w stronę liści najpierw napotykamy węzeł dodawania, mający dwa konary (tzw. węzły-dzieci lub węzły-potomki), zawierające oba składniki dodawania. Lewy potomek odpowiada iloczynowi dwóch czynników. Lewym czynnikiem jest liczba 3, a prawym - suma liczb 3 i 4. Prawym potomkiem węzła dodawania najwyższego poziomu jest liczba 5. Warto zwrócić uwagę na to, że drzewiasta reprezentacja wyrażenia arytmetycznego nie wymaga stosowania nawiasów ani znajomości zasad, określających priorytet operatorów. Struktura drzewa arytmetycznego w jednoznaczny sposób określa sposób i kolejność przeprowadzenia wszystkich obliczeń.
Rysunek 2.3 Drzewo arytmetyczne odpowiadające wyrażeniu 2 * (3 + 4) + 5.
Wszystkie węzły drzewa arytmetycznego reprezentować będziemy jako obiekty klas wyprowadzonych jako klasy pochodne jednej klasy podstawowej Node. Bezpośrednio z klasy Node wyprowadzamy dwie nowe klasy: NumNode (reprezentującą liczby) oraz BinNode (reprezentującą operatory dwuargumentowe). Aby uprościć wstępna wersję programu założymy, że nasz program będzie obsługiwał tylko dwa operatory dwuargumentowe: dodawanie i mnożenie. Odpowiadać im będą klasy AddNode i MultNode; oczywiście obie będą dziedziczyć właściwości klasy BinNode. Na rysunku 2.4 przedstawiamy opisaną powyżej hierarchię klas. Uwidocznione na tym schemacie klasy abstrakcyjne charakteryzują się tym, iż nie mogą posłużyć bezpośrednio do tworzenia obiektów - mogą jedynie służyc jako klasy podstawowe innych klas. Zagadnienie to omówimy w dalszej części tego rozdziału.
Rysunek 2.4. Hierarchia klas reprezentujących węzły drzewa arytmetycznego.
Jakie operacje chcielibyśmy wykonywać na węzłach drzewa? Niewątpliwie chcielibyśmy móc obliczać ich wartość i w pewnym momencie je zniszczyć. Wartość węzła wyznacza metoda Calc. Oczywiście wyznaczenie wartości niektórych węzłów może wymagać rekurencyjnego wyznaczenia wartości jego potomków. Metoda Calc nie modyfikuje węzła, więc jej deklarację opatrzono modyfikatorem const. Ponieważ każdy rodzaj węzła musi dostarczyć własną implementację metody Calc, deklarujemy tę metodę jako funkcją wirtualną. Jednakże nie dostarczamy "domyślnej" implementacji metody Calc, którą można by zastosować w stosunku do dowolnego obiektu klasy Node. Metoda wirtualna nie posiadająca implementacji (np. poprzez bezpośrednią definicję lub dziedziczenie) zwana jest czystą funkcją wirtualną. W programie funkcje takie deklarujemy stosując "definicję" = 0. Przykładem czystej funkcji wirtualnej jest m.in. metoda Node::Calc.
Klasa zawierająca co najmniej jedna czystą funkcję wirtualną zwana jest klasą abstrakcyjną. Charakteryzuje się tym, że nie można utworzyć żadnego obiektu tej klasy. Tylko klasy dziedziczące z takich klas i dostarczające własne implementacje wszystkich czystych funkcji wirtualnych mogą służyć do tworzenia obiektów. Zwróćmy uwagę na to, że w naszej implementacji drzewa arytmetycznego posługujemy się obiektami klas AddNodes, MultNodes i NumNodes, natomiast nigdzie nie definiujemy (jawnie lub niejawnie) obiektów klas Nodes i BinNodes.
Należy zapamiętać podstawową regułę: jeżeli pewna klasa posiada choć jedną metodę wirtualną, najprawdopodobniej powinniśmy zdefiniować w niej wirtualny destruktor. Zwróćmy też uwagę na to, że skoro raz zdecydujemy się zapłacić dodatkową cenę za stosowanie tablicy wirtualnej, dodawanie kolejnych metod wirtualnych nie zwiększy rozmiaru obiektów. Dlatego dodanie wirtualnego destruktora nie prowadzi do pojawienia się w programie istotnego narzutu.
W przypadku naszego programu możemy spodziewać się, że niektóre węzły będą musiały w swoich destruktorach zniszczyć swoich potomków. Dlatego naprawdę potrzebujemy wirtualnego destruktora. Destruktorów klas podstawowych nie możemy zdefiniować jako czystych funkcji wirtualnych, gdyż faktycznie są wywoływane przez destruktory klas pochodnych. Dlatego destruktor klasy Node zdefiniowałem jako funkcję pustą (mimo iż zdefiniowałem go jako funkcję wklejaną [inline], kompilator wygeneruje osobny kod tej funkcji, gdyż musi umieścić adres tej funkcji w tablicy wirtualnej).
class Node { public: virtual ~Node () {} virtual double Calc () const = 0; }; |
Obiekty klasy NumNode przechowują składową typu double, którą inicjują w konstruktorze. W definicji tej kasy dostarczamy też implementację metody wirtualnej Calc. W tym konkretnym przypadku metoda Calc po prostu zwraca wartość, przechowywaną w danym węźle.
class NumNode: public Node { public: NumNode (double num) : _num (num ) {} double Calc () const; private: const double _num; }; double NumNode::Calc () const { cout << "Numeric node " << _num << endl; return _num; } |
Każdy obiekt klasy BinNode posiada dwóch potomków, będących wskaźnikami do obiektów klasy Node. Składowe te są inicjowane w konstruktorze, a wskazywane przez nie obiekty są niszczone w destruktorze — tłumaczy to, dlaczego mogłem zdefiniować je jako wskaźniki stałe (ale nie jako wskaźniki do stałych, gdyż w kontekście wskazywanych przez nie obiektów muszę wywoływać destruktory, które w żadnym wypadku nie mogą być metodami stałymi). Metodę Calc dziedziczymy z klasy Node, pozostaje więc ona czystą funkcję wirtualną. Dlatego nie możemy utworzyć żadnego obiektu klasy BinNode; żeby ta klasa była do czegokolwiek przydatna, będziemy musieli z niej wyprowadzić klasy pochodne, w których podamy konkretną implementację tej metody.
class BinNode: public Node { public: BinNode (Node * pLeft, Node * pRight) : _pLeft (pLeft), _pRight (pRight) {} ~BinNode (); protected: Node * const _pLeft; Node * const _pRight; }; BinNode::~BinNode () { delete _pLeft; delete _pRight; } |
W tym miejscu po raz pierwszy możemy dostrzec zalety polimorfizmu. Węzeł operacji wduargumentowej (BinNode) może mieć potomków dowolnego typu wyprowadzonego z klasy Node. Każdy z nich może być więc węzłem reprezentującym liczbę, dodawanie lub mnożenie. Istnieje dziewięć różnych kombinacji potomków - tworzenie dla każdej z nich osobnej klasy byłoby dość nieroztropnie (wyobraźmy sobie na przykład twór typu AddNodeWithLeftMultNodeAndRightNumberNode). Nie mamy wyboru - musimy przechowywać wskaźniki do potomków jako wskaźniki ogólnego typu, wskazujące po prostu na jakiś węzeł, czyli obiekt klasy Nodes. Niemniej jednak musimy jakoś uwzglednić fakt, że wywoływane przez nas destruktory obiektów potomnych są zupełnie różnymi funkcjami. Na przykład destruktor klasy AddNode jest zupełnie inna funkcją niz destruktor klasy NumNode (który jest zupełnie pozbawiony treści) Dlatego właśnie musieliśmy zadeklarować destruktory wszystkich obiektów klasy Nodes jako funkcje wirtualne.
Zwróćmy uwagę na to, że obie składowe klasy BinNode nie są prywatne (private), lecz chronione (protected). Składowe prywatne są nieco słabiej zabezpieczone przed dostępem z zewnątrz niż składowe prywatne. Do prywatnej składowej lub metody nie ma dostępu poza implementacją danej klasy (lub jej "przyjaciół"). Zakaz ten dotyczy nawet klas pochodnych. Gdybyśmy składowe _pLeft i _pRight umieścili w prywatnej implementacji klasy, musielibyśmy zdefiniować publiczne metody służące do odczytywania lub modyfikowania ich wartości. Oznaczałoby to, że w ten sposób tak naprawdę udostępnilibyśmy je całemu światu. Definiując je jako składowe chronione (protected) udostępniamy je klasom pochodnym, wyprowadzonym przez dziedziczenie z klasy BinNode. Dostęp do tych składowych jest więc możliwy tylko z kodu implementacji danej klasy, jej przyjaciół lub z klas pochodnych.
Tabela 1
Specyfikator dostępu | Kto ma dostęp do takiej składowej? |
public | Każdy |
protected | Dana klasa, jej przyjaciele i klasy pochodne |
private | Wyłącznie dana klasa i jej przyjaciele |
Klasę AddNode wyprowadzamy z klasy BinNode:
class AddNode: public BinNode { public: AddNode (Node * pLeft, Node * pRight) : BinNode (pLeft, pRight) {} double Calc () const; }; |
Klasa ta zawiera własną definicję metody Calc. Tutaj ponownie dostrzegamy zalety polimorfizmu. Po prostu żądamy od węzłów potomnych, by same wyznaczyły swoje wartości. Ponieważ metoda Calc jest wirtualna, każdy z obiektów potomnych wykona swoją pracę prawidłowo, zgodnie z rzeczywistym typem obiektu, a nie na podstawie deklaracji obiektu, wskazywanego przez wskaźnik. (Node *). Jako wartość metody AddNode::Calc zwracana jest suma obu wyników działania funkcji Calc.
double AddNode::Calc () const { cout << "Adding\n"; return _pLeft->Calc () + _pRight->Calc (); } |
Zwróćmy uwagę na to, że metoda AddNode::Calc ma bezpośredni dostęp do składowych _pLeft i _pRight swojej klasy bazowej
Na koniec przyjrzyjmy się implementacji klasy MultNode oraz prostemu programowi, w którym testujemy nasze klasy.
class MultNode: public BinNode { public: MultNode (Node * pLeft, Node * pRight) : BinNode (pLeft, pRight) {} double Calc () const; }; double MultNode::Calc () const { cout << "Mnozenie\n"; return _pLeft->Calc () * _pRight->Calc (); } int main () { // ( 20.0 + (-10.0) ) * 0.1 Node * pNode1 = new NumNode (20.0); Node * pNode2 = new NumNode (-10.0); Node * pNode3 = new AddNode (pNode1, pNode2); Node * pNode4 = new NumNode (0.1); Node * pNode5 = new MultNode (pNode3, pNode4); cout << "Wyznaczanie wartości drzewa arrytmetycznego\n"; // Prosimy korzeń drzewa o wyznaczenie swojej wartości double x = pNode5->Calc (); cout << x << endl; delete pNode5; // i wszystkie węzły potomne } |
Czy drogi Czytelniku uważasz, że potrafisz napisać bardziej wydajny kod bez posługiwania się polimorfizmem? Przemyśl to dobrze! Jeżeli wciąż masz wątpliwości, wybierz się na spacer po alternatywnym świecie języka C. |