Podręcznik
3. Dziedziczenie i polimorfizm
3.2. Polimorfizm
Podejście do dziedziczenia zaprezentowane w poprzednim rozdziale pozwala nam na uzupełnianie definicji klas bazowych o nowe elementy - w ten sposób tworzymy nowe byty, różniące się od wcześniej zdefiniowanych cechami (polami). Czasem jednak można stwierdzić, że istotą różnicy między dwoma bytami (klasami) jest zmiana zachowania, przykładowo: człowiek to jest taka małpa, która umie mówić. Tą różnicę można zinterpretować na dwa sposoby - w literalnym podejściu zakładamy, że człowiek ma dodatkową metodę - mów - która rozszerza listę metod małpy. Tak więc możemy w dziedziczeniu rozszerzać katalog zachowań obiektów definiując dla nich nowe metody - to też w zasadzie wynika z poprzedniego rozdziału. Możemy także stwierdzić, że dźwięk wydawany przez człowieka to mowa - a w przypadku małpy niekoniecznie. Czyli - mamy tą samą metodę (dajGłos), która ma inną implementację (inne działanie) u różnych klas. Taką sytuację nazywa się polimorfizmem:
Ktoś kto korzysta z obiektu klasy człowiek czy też małpa – nie musi wiedzieć którego typu jest dany osobnik by zmusić go do wydania głosu – wystarczy że wie że osobnik wydaje głosy. Wywołaniem odpowiedniej wersji metody zajmie się kompilator.
class CMalpa {
public:
virtual void dajGlos() {
cout << "Hyyyhmyyym\n";
}
};
class CCzlowiek : public CMalpa {
public:
virtual void dajGlos() {
cout << "Auuu, boli\n";
}
};
int main(int argc, char *argv[])
{
CMalpa m;
CCzlowiek c;
CMalpa *cm;
m.dajGlos();
c.dajGlos();
cm = &m;
cm->dajGlos();
cm = &c;
cm->dajGlos();
}
Technicznie - zachowania polimorficzne w C++ mogą, ale nie muszą wystąpić. Wcześniej omawialiśmy mechanizm przesłaniania nazw (pól, ale też metod). Jeśli z powyższego kodu usunęlibyście wystąpienia słowa virtual zamiast polimorfizmu wprowadzilibyśmy właśnie przesłanianie. Wtedy, w powyższym kodzie każde odwołanie przez wskaźnik na małpę (cm) będzie powodowało wywołanie metody dla małpy.
Jeśli w definicji klasy użyjecie słowa virtual - to obsługa takiej metody zmieni się. W miejsce bezpośredniego wywołania zostanie przeszukana tablica funkcji wirtualnych przynależąca do danego obiektu – i wyszukana wersja najbliższa w hierarchii dziedziczenia. Przy czym - co stanowi clou polimorfizmu - nie jest istotne poprzez wskaźnik na który z typów bazowych odwołujemy się do danego obiektu - wersja metody wirtualnej, która zostanie wywołana - jest determinowana tożsamością obiektu.
Technikalia implementacji polimorfizmu w C++ są następujące: Po pierwsze - nie może zmieniać się liczba parametrów (sygnatura) metody wirtualnej. Po drugie - metoda zaczyna zachowywać się jak wirtualna w momencie pierwszego pojawienia się słowa virtual. Zachowanie wirtualne dla metody już zdefiniowanej jako wirtualna trwa - i nie zmienia tego fakt występowania (lub nie występowania) słowa virtual w kolejnych definicjach metod w klasach pochodnych. Rodzaj dziedziczenia nie wpływa na zachowanie się funkcji wirtualnych (zmienia się jedynie zasięg ich widoczności). No i na koniec – metody statyczne nie mogą być wirtualne (metoda statyczna może zostać wywołana bez obiektu - vide nie ma tożsamości, i nie ma na jakiej podstawie podjąć decyzji o wersji metody do wywołania).
Przykład jak to działa w praktyce?
#include <iostream>
class A {
public:
virtual void f1() { std::cout << "A f1\n"; }
void f2() { std::cout << "A f2\n"; }
};
class B : public A {
public:
void f1() { std::cout << "B f1\n"; }
virtual void f2() { std::cout << "B f2\n"; }
};
class C : public B {
public:
void f1() { std::cout << "C f1\n"; }
virtual void f2() { std::cout << "C f2\n"; }
};
int main(int argc, char *argv[]) {
A a; B b; C c;
A* pab, *pac;
B* pbc;
pab = &b;
pac = &c;
pbc = &c;
std::cout << "Klasa A:\n";
a.f1();
a.f2();
std::cout << "Klasa B:\n";
b.f1();
b.f2();
std::cout << "Klasa B jako A:\n";
pab->f1();
pab->f2();
std::cout << "Klasa C:\n";
c.f1();
c.f2();
std::cout << "Klasa C jako A:\n";
pac->f1();
pac->f2();
std::cout << "Klasa C jako B:\n";
pbc->f1();
pbc->f2();
return 0;
}
Jak widzicie, metoda f2 nie zawsze jest wirtualna - metoda f1 zawsze będzie. Ponieważ zachowanie takie potrafi być nieczytelne - w prawdziwych aplikacjach rzadko definiujemy klasy bezpośrednio jedna pod drugą, więc łatwo się pogubić które metody są wirtualne a które nie - to C++ pozwala nam na opcjonalne jawne wyrażenie chęci nadpisania metody. Jest nią słowo kluczowe override - jeśli zamieścimy je na końcu jej deklaracji, to kompilator sprawdzi, czy metoda jest gdzieś wcześniej w hierarchii oznaczona jako wirtualna, i - jeśli nie - zgłosi błąd. Gorąco zachęcam Was do stosowania override we własnych kodach.
Drugim potencjalnym ułatwieniem jest słowo kluczowe final - które mówi nam, że dana metoda już osiągnęła doskonałość, i nie może być dalej nadpisywana ani przysłaniana. Próba redefinicji metody oznaczonej jako final w klasie pochodnej spowoduje błąd kompilacji.
Popatrzcie na wcześniejszy przykład, uzupełniony o override i final, oraz - dla przykładu - z zamienionym stosowaniem wskaźników na referencję.
#include <iostream>
class A {
public:
virtual void f1() { std::cout << "A f1\n"; }
void f2() { std::cout << "A f2\n"; }
virtual void f3() { std::cout << "A f3\n"; }
};
class B : public A {
public:
void f1() override { std::cout << "B f1\n"; }
virtual void f2() { std::cout << "B f2\n"; }
void f3() override final { std::cout << "A f3\n"; }
};
class C : public B {
public:
void f1() override { std::cout << "C f1\n"; }
void f2() override { std::cout << "C f2\n"; }
// próba redefinicji f3 skończy się błędem:
// void f3() override {}
};
int main(int argc, char *argv[]) {
A a; B b; C c;
A& ab{b};
A& ac{c};
B& bc{c};
std::cout << "Klasa A:\n";
a.f1();
a.f2();
std::cout << "Klasa B:\n";
b.f1();
b.f2();
std::cout << "Klasa B jako A:\n";
ab.f1();
ab.f2();
std::cout << "Klasa C:\n";
c.f1();
c.f2();
std::cout << "Klasa C jako A:\n";
ac.f1();
ac.f2();
std::cout << "Klasa C jako B:\n";
bc.f1();
bc.f2();
return 0;
}
Metody wirtualne mogą być wywoływane także bezpośrednio w implementacji innych metod w klasach bazowych – zostanie wtedy automatycznie wybrana odpowiednia wersja danej metody, odpowiadająca tożsamości obiektu wywołującego. W ten sposób możemy wykorzystywać przy tworzeniu kodu zachowanie jeszcze niezdefiniowane, i zależne od typu obiektu z którym pracujemy. Przykładowo - możemy wykorzystać fakt, że można narysować figurę, mimo że nie wiemy jeszcze jak:
class CFigura {
public:
virtual void rysuj() { }
void przesun(int _x, int _y) {
m_x = _x;
m_y = _y;
rysuj();
};
private:
int m_x{0};
int m_y{0};
};
class CKolo :public CFigura {
public:
virtual void rysuj() {
// kod rysowania
}
};
Jawne wywołanie metody bazowej w określonej wersji jest możliwe poprzez podanie poprzedzonej dwukropkiem nazwy jej klasy. Nie ma w C++ możliwości niejawnego wywołania odziedziczonej instancji. Ogólnie – zasięg widoczności poszczególnych pól i metod w klasach może być traktowany jako zagnieżdżony z punktu widzenia dziedziczenia:
class A {
public:
virtual void f() { cout << "A f1\n"; }
};
class B : public A {
public:
void f() override {
A::f();
cout << "B f1\n";
}
};
class C : public B {
public:
void f() override {
A::f();
B::f();
cout << "C f1\n";
}
};