20.4 Metody wirtualne i polimorfizm

W klasie pochodnej można zdefiniować metodę o sygnaturze i typie zwracanym (patrz rozdział o funkcjach ) takich samych jak dla pewnej metody z klasy bazowej. Nie jest to przeciążenie, tylko przesłonięcie: sygnatury są bowiem te same (a funkcje przeciążane mają tę samą nazwę, ale różne sygnatury).

Załóżmy następującą sytuację:

       class A {
           // ...
           void fun() { ... }
           // ...
       };

       class B : public A {
           // ...
           void fun() { ... }
           // ...
       };
Zdefiniujmy teraz
       A a, *pa  = new A, *pab = new B,
            &raa = a,     &rab = *pab;
A zatem Przypomnijmy, że

Typ statyczny obiektu wskazywanego przez wskaźnik lub referencję określony jest przez deklarację tego wskaźnika (referencji). Typ dynamiczny to rzeczywisty typ obiektu, do którego odnosi się ten wskaźnik lub to referencja. Typ dynamiczny obiektu może być zazwyczaj określony dopiero podczas wykonania programu; typ statyczny jest znany już w czasie kompilacji.

Przypuśćmy teraz, że wywołujemy funkcję fun za pomocą zmiennych a, pa, pab, raarab i pytamy, która z metod: czy ta z klasy  A, czy ta z klasy  B, zostanie wywołana. Otóż dla wszystkich wywołań decyduje tu typ statyczny; wywołania

       a.fun(); pa->fun(); pab->fun(); raa.fun(); rab.fun();
wszystkie spowodują wywołanie funkcji fun z klasy  A, mimo że w trzecim i piątym przypadku obiekt, na rzecz którego nastąpi wywołanie, jest w rzeczywistości obiektem klasy pochodnej  B, a w klasie tej metoda fun została przedefiniowana, przesłaniając wersję odziedziczoną z klasy bazowej.

Dla znających Pythona czy Javę może to być zaskoczenie. Tam bowiem, jak w większości języków obiektowych, decyduje typ dynamiczny: jeśli obiekt, na rzecz którego wywołujemy metodę, jest klasy pochodnej względem tej, która jest typem statycznym wskaźnika (referencji) do tego obiektu, to wywołana będzie wersja tej metody pochodząca z klasy pochodnej (jeśli została tam przedefiniowana). Mówimy wtedy, że metody są wirtualne. A zatem w Javie wszystkie metody (prócz finalnych i prywatnych) wirtualne. Klasy w których istnieją metody wirtualne, nazywamy klasami polimorficznymi, bo wywołanie ich poprzez wskaźnik (referencję) pewnego typu zależy od typu obiektu na który ten wskaźnik wskazuje, ma zatem „wiele kształtów”. A zatem w Javie klasy, prócz finalnych, polimorficzne.

Trzeba jednak zdawać sobie sprawę, że ceną za polimorfizm jest pewna utrata wydajności. Dla wywołań na rzecz obiektów klas niepolimorficznych odpowiednia metoda jest wybierana już w czasie kompilacji na podstawie typu statycznego. Mówimy, że następuje wtedy wczesne wiązanie (ang. early binding).

Typ dynamiczny obiektu wskazywanego przez wskaźnik lub referencję może być natomiast określony dopiero w czasie wykonania. Kompilator, napotkawszy wywołanie metody z klasy polimorficznej, nie może umieścić w pliku wykonywalnym kodu odpowiadającego wywołaniu konkretnej funkcji. Zamiast tego umieszczany jest tam kod sprawdzający prawdziwy typ obiektu i wybierający odpowiednią metodę. Mówimy, że następuje wtedy późne wiązanie (ang. late binding). Tak więc każde wywołanie metody wirtualnej powoduje narzut czasowy w trakcie wykonania. Wybranie odpowiedniej metody wymaga też dostępu do informacji o różnych wersjach metody w klasach dziedziczących. Informacja ta jest zwykle umieszczana w specjalnej tablicy, której adres jest przechowywany w każdym obiekcie klasy polimorficznej. Obiekt taki musi być zatem większy niż obiekt analogicznej klasy niepolimorficznej — polimorfizm powoduje zatem również narzut pamięciowy.

W C++, przede wszystkim właśnie ze względu na wydajność, podejście do polimorfizmu jest nieco inne niż w większości innych języków obiektowych. Jako programiści mamy mianowicie możliwość wyboru: czy chcemy, aby definiowana klasa była polimorficzna, czy też z polimorfizmu rezygnujemy na rzecz podniesienia wydajności. Domyślnie nowo definiowane klasy nie są polimorficzne, a zatem definiowane w nich metody nie są wirtualne. Jeśli w programie następuje wywołanie, poprzez wskaźnik lub referencję, dowolnej metody na rzecz obiektu klasy niepolimorficznej, kompilator umieszcza od razu wywołanie konkretnej metody w kodzie wynikowym. Kieruje się przy tym wyłącznie typem zadeklarowanym (statycznym) wskaźnika lub referencji.

Aby definiowana klasa była polimorficzna, wystarczy jeśli choć jedna metoda tej klasy będzie wirtualna. W szczególności może to być destruktor (ale nie konstruktor — ten wirtualny nie może być nigdy).

Deklaracja metody jako wirtualnej musi mieć miejsce w klasie bazowej. Jeśli metoda została zadeklarowana w klasie bazowej jako wirtualna, to wersje przesłaniające tę metodę we wszystkich klasach pochodnych (nie tylko „synach”, ale i „wnukach”, „prawnukach”,...) są też wirtualne. Ponowne deklarowanie ich w klasach pochodnych jako wirtualnych jest dopuszczalne i zalecane, bo zwiększa czytelność kodu, ale w zasadzie zbędne.

Metodę deklarujemy jako wirtualną przez dodanie modyfikatora virtual w jej deklaracji. W klasach pochodnych, jak powiedzieliśmy, powtarzać tego nie musimy; na przykład:

       class A {
           // ...
           virtual double fun(int,int);
           // ...
       };

       class B : public A {
           // ...
           double fun(int,int);
           // ...
       };
Zdefiniujmy jak przedtem:
       A a, *pa  = new A, *pab = new B,
            &raa = a,     &rab = *pab;
Teraz klasy są polimorficzne, metoda fun jest wirtualna, a więc zadziała mechanizm późnego wiązania. Wywołania
       a.fun(), pa->fun(), pab->fun(), raa.fun(), rab.fun()
spowodują teraz w trzecim i piątym przypadku wywołanie metody fun z klasy B, gdyż: Oczywiście, nic by się złego nie stało, gdybyśmy w klasie B nie przedefiniowali metody fun. Wywołana zostałaby tak czy owak wersja widoczna w klasie B; gdyby fun nie została przesłonięta, to w klasie B widoczną wersją metody fun byłaby ta odziedziczona z klasy  A. Nawet zresztą gdy metodę przedefiniowaliśmy, możemy jawnie „wyłączyć” polimorfizm: wywołania
       pab->A::fun();   rab.A::fun();
spowodują wywołanie wersji funkcji fun z klasy bazowej  A nawet jeśli wersja przesłaniająca w klasie  B istnieje i mimo że obiekt, na rzecz którego następuje wywołanie, jest typu B, a wywołanie jest poprzez wskaźnik lub referencję. Tak więc jawna kwalifikacja nazwy metody (za pomocą nazwy klasy) powoduje, że normalny mechanizm polimorfizmu nie jest używany — takie wywołanie będzie „wcześnie wiązane”. Skoro tak, to możliwe jest też wywołanie
       b.A::fun()
bezpośrednio na rzecz obiektu klasy  B (nie poprzez wskaźnik lub referencję). Odwrotna sytuacja nie jest oczywiście możliwa nigdy: nie można, nawet używając nazw kwalifikowanych, wywołać metody z klasy  B poprzez nazwę obiektu klasy bazowej  A (a nie wskaźnika lub referencji):
       a.B::fun() // Źle!!
byłoby nielegalne.


Przesłaniając w klasie pochodnej metodę dziedziczoną z klasy bazowej możemy zawęzić jej dostępność (ale nie rozszerzyć —  odwrotnie niż w Javie!).

Jaka zatem będzie dostępność wirtualnej metody wywoływanej poprzez wskaźnik typu  A* do obiektu klasy  B, jeśli w klasie pochodnej  B dostępność tej metody zawęziliśmy? Otóż będzie ona taka, jak w klasie, do której odnosi się wskaźnik lub referencja (typ statyczny), a nie taka jak w klasie obiektu (typ dynamiczny). Jeśli metoda jest publiczna w klasie bazowej, to odpowiednie wersje tej metody z klas pochodnych będą dostępne poprzez wskaźnik lub referencja do obiektu klasy bazowej, nawet jeśli w klasach pochodnych ta sama składowa jest prywatna! Rozpatrzmy przykład:


P163: figur.cpp     Dostępność funkcji wirtualnych

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  class Figura {
      5.  protected:
      6.      int  height;
      7.  public:
      8.      Figura(int height = 0) : height(height)
      9.      { }
     10.  
     11.      virtual void what() {
     12.          cout << "Figura: h=" << height <<endl;
     13.      }
     14.  };
     15.  
     16.  class Prostokat : public Figura {
     17.  private:
     18.      int  base;
     19.      void what() {
     20.          cout << "Prostokat: (h,b)=(" << height
     21.               << "," << base << ")\n";
     22.      }
     23.  public:
     24.      Prostokat(int height = 0, int base = 0)
     25.          : Figura(height), base(base)
     26.      { }
     27.  };
     28.  
     29.  int main() {
     30.      Figura    *f = new Prostokat(4,5)  , &rf = *f;
     31.      Prostokat *p = new Prostokat(40,50);
     32.  
     33.        // what w Prostokat private, ale w Figura nie!
     34.      f->what();                         // Prostokat
     35.      rf.what();                         // Prostokat
     36.  
     37.        // p->what(); nie, bo what prywatne w Prostokat
     38.        // Ale ponizsze legalne!
     39.      ((Figura*)p)->what();              // Prostokat
     40.      ((Figura&)*p).what();              // Prostokat
     41.  
     42.        // OK: wersja publiczna z klasy bazowej Figura
     43.      p->Figura::what();                 // Figura
     44.  }

Wirtualna funkcja what jest w klasie Figura zadeklarowana jako public (linia 11). W klasie Prostokat metoda ta jest przesłaniana, a wersja przesłaniająca jest private (linia 19). Zmienne frf są typu Figura*Figura&, ale obiektem przez nie wskazywanym jest obiekt klasy Prostokat (linia 30). Tak więc typem dynamicznym wskazywanego obiektu jest Prostokat, ale statycznym Figura. Przyjrzyjmy się wywołaniom z linii 34 i 35. Wywołujemy tam metodę what z klasy obiektu, czyli z klasy Prostokat, bo metoda jest wirtualna i brany jest pod uwagę typ dynamiczny. W tej klasie metoda what jest prywatna, ale mimo to wywołanie się powiedzie: typ statyczny obieku do którego odnoszą się zmienne frf to Figura, a tam metoda ta była publiczna.
    Prostokat: (h,b)=(4,5)
    Prostokat: (h,b)=(4,5)
    Prostokat: (h,b)=(40,50)
    Prostokat: (h,b)=(40,50)
    Figura: h=40
Natomiast zakomentowane wywołanie z linii 37 nie powiodłoby się. Tam bowiem typem statycznym obiektu wskazywanego przez p jest obiekt klasy Prostokat, a w tej klasie metoda what jest prywatna.

Zwróćmy uwagę na wywołania z linii 39 i 40. Zmienna p jest co prawda wskaźnikiem do obiektu typu pochodnego (i na taki obiekt wskazuje), ale przed wywołaniem rzutujemy wartość tego wskaźnika na typ Figura*, a zatem zmieniamy typ statyczny wskazywanego obiektu na Figura, w której to klasie what jest metodą publiczną — analogiczny mechanizm zastosowaliśmy dla referencji rf. Zatem oba te wywołania powiodą się.

Ostatnia linia wydruku jest rezultatem instrukcji z linii 43 programu. Obiektem wskazywanym przez zmienną wskaźnikową p jest co prawda obiekt klasy Prostokat, ale wywołaliśmy jawnie, poprzez kwalifikację nazwą zakresu, metodę what z klasy bazowej Figura. Wywołanie zatem nie jest polimorficzne. Było możliwe, gdyż w klasie Figura metoda what jest publiczna.


Przekonajmy się jeszcze, że polimorfizm rzeczywiście kosztuje, a zatem nie powinien być stosowany bez potrzeby. W przykładzie poniżej definiujemy trzy bardzo podobne klasy:


P164: polysiz.cpp     Wzrost rozmiaru obiektów klas polimorficznych

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  class A {
      5.      int i;
      6.  public:
      7.      A() : i(0)
      8.      { }
      9.  };
     10.  
     11.  class B {
     12.      int i;
     13.  public:
     14.      B() : i(0)
     15.      { }
     16.      ~B()
     17.      { }
     18.  };
     19.  
     20.  class C {
     21.      int i;
     22.  public:
     23.      C() : i(0)
     24.      { }
     25.      virtual ~C()
     26.      { }
     27.  };
     28.  
     29.  int main() {
     30.     cout << "sizeof(A): " << sizeof(A) << endl;
     31.     cout << "sizeof(B): " << sizeof(B) << endl;
     32.     cout << "sizeof(C): " << sizeof(C) << endl;
     33.  }

Klasa  B jest identyczna jak klasa  A, tyle że definiuje destruktor (zresztą w tej klasie niepotrzebny). Destruktor ten nie jest wirtualny, a więc klasa nie jest polimorficzna. Natomiast klasa  C jest identyczna jak  B, ale jej destruktor jest wirtualny. Wystarczy to, aby klasa ta była polimorficzna (choć, póki co, żadna inna klasa z niej nie dziedziczy). W liniach 30-32 drukujemy rozmiary obiektów wszystkich trzech klas:
    sizeof(A): 4
    sizeof(B): 4
    sizeof(C): 8
Widzimy, że dopóki klasa nie jest polimorficzna, rozmiar obiektu jest taki, jak wynika z rozmiaru pól. Samo dodanie metody (w tym przypadku destruktora) nie zwiększa rozmiaru obiektów. Natomiast dodanie polimorfizmu, jak dla klasy  C, powoduje wzrost rozmiaru obiektu, w naszym przykładzie o cztery bajty. W przypadku tej prostej klasy oznacza to zatem wzrost o 100%.

Często się zdarza, że pola klasy są głównie typu wskaźnikowego; obiekty takich klas zwykle nie są duże. Narzut spowodowany polimorfizmem, a więc obecnością dodatkowej informacji (o tzw. tablicy funkcji wirtualnych) w każdym obiekcie klasy może być więc dość spory.

T.R. Werner, 25 lutego 2017; 22:31