Implementacja polimorfizmu

Jest zupełnie oczywiste, że aby polimorfizm mógł w ogóle działać, każdy obiekt musi przechowywać informacje o swoim faktycznym typie. Na podstawie tych informacji możnaby dynamicznie ustalać, która implementacja danej metody powinna być wywoływana w kontekście danego obiektu. Bodaj najprościej byłoby umieścić w obiektach specjalny wskaźnik, wskazujący na tablicę z adresami tych metod wirtualnych, które powinny być wywoływane w kontekście danego obiektu. I właśnie w taki sposób implementuje się polimorfizm!

Klasę posiadającą choćby jedną metodę wirtualna nazywamy klasą polimorficzną. Każdy obiekt klasy polimorficznej posiada ukrytą składową, będącą wskaźnikiem do tzw. tablicy wirtualnej. Tablica wirtualna (ang. virtual table) zawiera adresy wszystkich wirtualnych funkcji składowych, zdefiniowanych w klasie danego obiektu. Jeżeli do takiego obiektu odwołujemy się poprzez wskaźnik lub referencję, kompilator wygeneruje kod maszynowy, który wyłuska z ukrytego w obiekcie wskaźnika adres tablicy wirtualnej, po czym wywoła metodę wirtualną o adresie odczytanym z tej tablicy. Technikę tę nazywamy pośrednim adresowaniem funkcji (ang. indirect call).

Rysunek 2.1. Każdy obiekt klasy CelestialBody posiada ukrytą składową, zawierającą adres tablicy wirtualnej (vtable). Tablica wirtulana zawiera adresy wszystkich metod wirtualnych, dostępnych w danej klasie. W naszym przykładzie w pierwszym elemencie tej tablicy znajduje się adres destruktora klasy CelestialBody.

Każdej klasie polimorficznej odpowiada osobna tablica wirtualna. Dlatego obiekty klasy pochodnej w swoim ukrytym wskaźniku przechowują adres innej tablicy wirtualnej, niż obiekty klasy bazowej. Ogólnie, jeżeli w pewnej klasie pochodnej Poch zdefiniuje się osobną implementację metody met klasy podstawowej Podst, to w każdym obiekcie x klasy Poch automatycznie umieszczona będzie ukryta zmienna adresowa zawierająca adres tablicy wirtualnej, przechowującej adresy wszystkich metod wirtualnych klasy pochodnej, w tym adres funkcji Poch::met.

Rysunek 4.2. Ukryta zmienna wskaźnikowa obiektu klasy Star wskazuje na inną tablicę wirtualną. Podobnie jak poprzednio pierwszy element tej tablicy zawiera adres destruktora. Jednak tym razem jest to destruktor klasy Star.

Zwróćmy uwagę na to, że uwzględniając typ wskaźnika kompilator zawsze może rozstrzygnąć, czy powinien wygenerować pośrednie wywołanie metody wirtualnej poprzez adres, umieszczony w tablicy wirtualnej, czy wywołać daną metodę bezpośrednio, czy może wkleić jej rozwinięty kod w punkcie wywołania. Jeżeli z deklaracji zmiennej wskaźnikowej wynika, że wskazuje ona na obiekt klasy polimorficznej i jeżeli za jej pośrednictwem usiłujemy posłużyć się metodą, której deklaracja zawiera modyfikator virtual, kompilator automatycznie wygeneruje wywołanie pośrednie. W przeciwnym razie kompilator albo wygeneruje bezpośrednie wywołanie kodu funkcji składowej albo, jeżeli deklaracja tej metody opatrzona jest modyfikatorem inline, wklei jej rozwinięty kod w miejscu jej wywołania. W każdym przypadku kompilator wpierw musi "zobaczyć" deklarację klasy bazowej (znajdującą się najprawdopodobniej w jednym z plików nagłówkowych). Bez tych informacji nie mógłby rozstrzygnąć, które metody klasy pochodnej są wirtualne. Nie wiedziałby więc, w jaki sposób interpretować kod źródłowy programu.

Narzut

Zanim Czytelnik postanowi całkowicie przestawić się na funkcje wirtualne, powinien zaznajomić się z informacjami dotyczącymi ich wpływu na rozmiar i prędkość działania programu. Jeśli chodzi o rozmiar programu, narzut związany z polimorfizmem wynosi jeden wskaźnik na każdy obiekt klasy polimorficznej. Dodatkowo każda klasa polimorficzna musi posiadać własną tablicę wirtualną, ale ten narzut nie ma specjalnego znaczenia. Powiększenie rozmiaru każdego obiektu nie stanowi problemu, jeżeli mamy do czynienia z niewielką liczbą stosunkowo dużych obiektów. Jednak związany z polimorfizmem wzrost zapotrzebowania na pamięć operacyjną zaczyna nabierać znaczenia w przypadku programów korzystających Z dużej liczby niewielkich obiektów.

Wywołanie funkcji wirtualnej jest wolniejsze od bezpośredniego wywołania funkcji i wyraźnie wolniejsze od wklejenia kodu funkcji typu inline w miejscu jej wywołania. To spowolnienie nie ma żadnego znaczenia w przypadku metod o dużym czasie wykonania. Jednak zadeklarowanie prostej funkcji wklejanej (inline), np. AtEnd (), jako funkcji wirtualnej może wyraźnie spowolnić nasze pętle.

Dlatego z góry należy odrzucić pomysł stworzenia jednej klasy (np. Object) jako klasy bazowej wszystkich używanych przez nas obiektów (miałaby ona wirtualny destruktor i, być może, dodatkowe pole typu int, zawierające unikatowy identyfikator klasy [kontrola typu w czasie rzeczywistym!], plus jakieś dodatkowe, kompilowane warunkowo metody, służące do odpluskwiania programów). Nie powinniśmy nawet przez chwilę myśleć o uczynieniu takiej klasy klasą bazową wszystkich naszych klas. Tak naprawdę wielu programistów narzekających na nieefektywność języka C++ pod względem czasu wykonania napisanych w nim programów jest ofiarami przeniesienia syndromu języka Smalltalk do języka C++. Oczywiście nie twierdzę, że Smalltalk jest słabym językiem programowania. Tam, gdzie rozmiar i prędkość programu nie mają żadnego znaczenia, Smalltalk góruje nad C++ niemal na wszystkich frontach. Jest to prawdziwy język obiektowy, pozbawiony niechlubnego dziedzictwa hakerskiego języka C. W porównaniu z językiem C++, Smalltalk znacznie lepiej integruje typy wbudowane z typami zdefiniowanymi przez użytkownika. Wszystkie używane w nim obiekty tworzą hierarchię o jednym, wspólnym korzeniu. Wszystkie metody są wirtualne (można je nawet przeciążać [ang. override] podczas wykonywania się programu!) Inny popularny język obiektowy, Java, usiłuje z kolei znaleźć kompromis pomiędzy "obiektywnością" i wydajnością. W języku Java wszystkie metody są wirtualne; wyjątek stanowią jednak metody, których definicja w sposób jawny zawiera modyfikator final (i w tym przypadku nie można ich przeciążyć).

Jeżeli zależy nam ma tym, aby nasze programy były małe i szybkie, powinniśmy pozostać przy języku C++ i w mądrze posługiwać się polimorfizmem. Dobry projekt klas polimorficznych skutkuje kodem, który prędkością nie ustępuje równoważnemu programowi, napisanemu w języku C, natomiast jest dużo łatwiejszy w późniejszej pielęgnacji. Ma to miejsce w sytuacji, gdy posługujemy się polimorfizmem w celu wyeliminowania z kodu ciągu instrukcji warunkowych (lub instrukcji switch) i zastąpienia ich pojedynczym wywołaniem funkcji wirtualnej.

Programiści myślący w kategoriach języka C powinni zwracać baczną uwagę na miejsca, w których chcieliby zastosować instrukcję switch lub skomplikowany ciąg instrukcji warunkowych. W języku C++ jest rzeczą zupełnie naturalną zastępowanie takich konstrukcji polimorfizmem.


Uwagi tłumacza

O typie obiektu decyduje konstruktor, wywoływany (jawnie lub niejawnie) podczas inicjacji obiektu. Jest to jedyne miejsce, w którym może ulec zmianie "ukryta zmienna wskaźnikowa". Ponieważ zmienna ta jest ukryta przed programistą, mamy gwarancję, że do momentu destrukcji obiektu żadna metoda danej klasy nie zmieni adresu zapisanego w tej ukrytej zmiennej. Zwróćmy uwagę na to, że każdy konstruktor zapisuje w niej adres tablicy wirtualnej zawierającej adresy metod wirtualnych swojej klasy. Jeżeli klasa ma kilka konstruktorów, posługiwać się one będą tą samą tablicą wirtualną. Konstruktory różnych klas posługują się różnymi tablicami wirtualnymi.

Powyższe uwagi pozwalają zrozumieć, dlaczego polimorfizm działa wyłącznie w stosunku do obiektów przekazywanych przez wskaźnik lub referencję, a nie działa przy przekazywaniu obiektów (do lub z funkcji) przez wartość. Tylko wskaźnik i referencja zapewniają nam bowiem dostęp do oryginalnej postaci obiektu, a więc do jego "prawdziwego" zestawu adresów metod wirtualnych. Przekazywanie obiektów przez wartość tak naprawdę zawsze wiąże się z przekazywaniem kopii obiektu. Kopię tę tworzy się na stosie funkcji -- jakżeżby inaczej -- przy pomocy odpowiedniego konstruktora. Ten konstruktor zapisuje w ukrytym wskaźniku adres swojej tablicy wirtualnej. Za pomocą takiej kopii nie można więc uzyskać informacji o rzeczywistym typie oryginalnego obiektu, co wyklucza możliwość zastosowania polimorfizmu.
Obiekty klas polimorficznych należy przekazywać przez referencję lub wskaźnik. Przekazanie takiego obiektu przez wartość unicestwia jego polimorfizm, jest więc niemal na pewno błędem programistycznym.