Dużo ważniejsze jest niepopełnianie krytycznych błędów niż dokonywanie genialnych decyzji.

Przemysław Gerschmann

Klasy, dziedziczenie, polimorfizm. Wirtualne metody są nieodzownym elementem programowania obiektowego umożliwiając przeciążania metod z klasy bazowej w klasach pochodnych. Deklarując metodę jako wirtualną w klasie bazowej możemy ją przeciążyć w klasie pochodnej dopasowując jej funkcjonowanie do obiektu pochodnego.

#include <iostream>

class Animal
{
public:   
    void showType() { std::cout << "Animal" << std::endl; }
};

class Cat: public Animal
{
public:   
    void showType() { std::cout << "Cat" << std::endl; }
};

class Dog: public Animal
{
public:   
    void showType() { std::cout << "Dog" << std::endl; }
};

int main()
{
    Animal *animal = new Animal();
    Animal *cat = new Cat();
    Animal *dog_1 = new Dog();
    Dog *dog_2 = new Dog();

    animal->showType();   /* Executes showType in Animal class. */
    cat->showType();      /* Executes showType in Animal class. */
    dog_1->showType();    /* Executes showType in Animal class. */
    dog_2->showType();    /* Executes showType in Dog class. */

    return 0;
}

Zmiana deklarację metody showType() w klasie Animal na wirtualną możemy ją przeciążać w klasach pochodnych. Zmieni to zachowanie kodu z poprzedniego przykładu. Obecnie wskaźniki będą wywoływały metodę showType() z klas użytych do utworzenia ich instancji.

#include <iostream>

class Animal
{
public:   
    virtual void showType() { std::cout << "Animal" << std::endl; }
};

class Cat: public Animal
{
public:   
    void showType() override { std::cout << "Cat" << std::endl; }
};

class Dog: public Animal
{
public:   
    void showType() override { std::cout << "Dog" << std::endl; }
};

int main()
{
    Animal *animal = new Animal();
    Animal *cat = new Cat();
    Animal *dog_1 = new Dog();
    Dog *dog_2 = new Dog();

    animal->showType();   /* Executes showType in Animal class. */
    cat->showType();      /* Executes showType in Cat class. */
    dog_1->showType();    /* Executes showType in Dog class. */
    dog_2->showType();    /* Executes showType in Dog class. */

    return 0;
}

Słowo kluczowe override, użyte w klasach pochodnych, jest opcjonalne z punktu widzenia kompilacji kodu, lecz z praktycznego punktu widzenia warto go używać. Wszelkie literówki w nazwach metod, czy brak możliwości przeciążenia metody zwrócą błędy na etapie kompilacji. Poszukiwanie tego typu błędów jest żmudne i irytujące, więc doświadczony programista zrobi wszystko by ich uniknąć.

Kompilacja kodu bez metod wirtualnych powoduje powiązanie wywołań funkcji z ich adresami w pamięci na etapie kompilacji (early binding). W przypadku istnienia metod wirtualnych na etapie kompilacji do klasy bazowej Animal dodawany jest wskaźnika *__vptr wskazujący wirtualną tablicę vtable, która zawiera mapowanie wszystkich wirtualnych metod klasy bazowej na ich przeciążone odpowiedniki . Wywołanie na obiekcie metody wirtualnej powoduje konieczność odnalezienie odpowiadającej mu tablicy vtable, po czym pobierany jest z niej wskaźnika na obszar pamięci z implementacją metody, a następuje jej wykonanie. Całość procesu odbywa się czasie wykonywania programu (late binding).

Każda klasa używająca wirtualnych funkcji, lub dziedzicząca po klasie która ich używa, otrzymuje na etapie kompilacji własną tablicę wirtualna.

vTable składa się z serii wpisów zawierających wskaźniki na funkcję, które wskazują najpóźniej odziedziczone implementacje metod wirtualnych. Bez wyjątku każda wirtualna metoda, która może zostać wywołana przez instancje klasy, posiada wpis w wirtualnej tablicy.

Po utworzeniu wirtualnej tablicy, najbardziej bazowa klasa zostaje wzbogacona przez kompilator o wskaźnik *__vptr. Przy tworzeniu obiektu *__vptr ustawiany jest by wskazywać na vTable utworzoną dla klasy z której utworzony został obiekt. W zaprezentowanym przykładzie kompilator utworzy 3 wirtualne tablice, po jednej dla każdej z klas. Dodatkowo klasa bazowa zostanie rozszerzona o *__vptr, który odziedziczą klasy pochodne:

class Animal
{
public:
    VirtualTable* __vptr;
    virtual void showType() { std::cout << "Animal" << std::endl; }
};

Gdy tworzony jest obiekt klasy Animal, *__vptr wskazuje na wirtualną tablicę klasy Animal. Tworzenie obiektów klasy Dog, czy Cat nadpisze *__vptr, by wskazywał na wirtualne tablice tych klas, czyli:

Animal *animal = new Animal(); // *_vptr = &Animal_vTable
Animal *cat = new Cat();       // *_vptr = &Cat_vTable
Animal *dog_1 = new Dog();     // *_vptr = &Dog_vTable
Dog *dog_2 = new Dog();        // *_vptr = &Dog_vTable

Konsekwentnie *cat pomimo, że jest wskaźnikiem typu Animal posiada na *__vptr wskazujący na vTable klasy Cat. Wywołując cat->showType() w pierwszym etapie metoda rozpoznawana jest jako metoda wirtualna. Następnie uzyskiwany jest dostęp do wirtualnej tablicy cat->__vptr i przeszukiwana jest ona w celu odnalezienia wskaźnika na metodę showType(). Ostatecznie wskazana metoda, Cat::showType(), jest wykonywana.

Koszt użycia metod wirtualnych

Z metodami wirtualnymi wiąże się z nimi dodatkowe zużycie pamięci, związane z koniecznością przechowywania vtable, oraz dodatkowe kroki związane z wywołaniem odpowiedniej implementacji metody. Czy należy się tym przejmować? O ile nie pracujemy na platformie w której liczy się każdy takt zegara, to myślę, że możemy pominąć koszt związany z obsługą funkcji wirtualnych.

Artykuł dodano 2022-09-27