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:

Polimorfizm to zróżnicowanie zachowań obiektu w zależności od jego  typu, przy zachowaniu jednolitego interfejsu. 

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";
  }
};