25,6 — Tabela wirtualna

Rozważ następujący program:

#include <iostream>
#include <string_view>

class Base
{
public:
    std::string_view getName() const { return "Base"; }                // not virtual
    virtual std::string_view getNameVirtual() const { return "Base"; } // virtual
};

class Derived: public Base
{
public:
    std::string_view getName() const { return "Derived"; }
    virtual std::string_view getNameVirtual() const override { return "Derived"; }
};

int main()
{
    Derived derived {};
    Base& base { derived };

    std::cout << "base has static type " << base.getName() << '\n';
    std::cout << "base has dynamic type " << base.getNameVirtual() << '\n';

    return 0;
}

Najpierw przyjrzyjmy się wywołaniu base.getName(). Ponieważ jest to funkcja niewirtualna, kompilator może użyć rzeczywistego typu base (Base), aby określić (w czasie kompilacji), że powinna ona zostać rozwiązana na Base::getName().

Chociaż wygląda prawie identycznie, wywołanie base.getNameVirtual() musi zostać rozwiązane inaczej. Ponieważ jest to wywołanie funkcji wirtualnej, kompilator musi użyć typu dynamicznego base , aby rozwiązać wywołanie, a typ dynamiczny base jest znany dopiero w czasie wykonywania. Dlatego dopiero w czasie wykonywania zostanie ustalone, że to konkretne wywołanie base.getNameVirtual() rozwiązuje się do Derived::getNameVirtual(), nie Base::getNameVirtual().

Jak więc właściwie działają funkcje wirtualne?

Wirtualna tabela

Standard C++ nie określa, w jaki sposób powinny być implementowane funkcje wirtualne (ten szczegół pozostaje w gestii implementacji).

Jednak implementacje C++ zazwyczaj implementują funkcje wirtualne przy użyciu formy późnego wiązania znanej jako tabela wirtualna.

Klasa wirtualna table to tabela przeglądowa funkcji używana do rozwiązywania wywołań funkcji w sposób dynamicznego/późnego wiązania. Wirtualna tabela czasami nosi inne nazwy, takie jak „vtable”, „tabela funkcji wirtualnych”, „tabela metod wirtualnych” lub „tabela wysyłkowa”. W C++ czasami nazywa się rozpoznawanie funkcji wirtualnych dynamiczne wysyłanie.

Nomenklatura

Oto łatwiejszy sposób myślenia o tym w C++:
Wczesne wiązanie/wysyłanie statyczne = rozwiązywanie przeciążenia bezpośredniego wywołania funkcji
Późne wiązanie = rozwiązywanie pośrednich wywołań funkcji
Dynamiczne wysyłanie = rozwiązywanie nadpisań funkcji wirtualnych

Ponieważ wiedza o tym, jak działa wirtualna tabela, nie jest konieczna, aby używać wirtualnej funkcji, tę sekcję można uznać za lekturę opcjonalną.

Wirtualna tabela jest w rzeczywistości dość prosta, chociaż opisanie jej słowami jest nieco skomplikowane. Po pierwsze, każda klasa korzystająca z funkcji wirtualnych (lub wywodząca się z klasy korzystającej z funkcji wirtualnych) ma odpowiednią tabelę wirtualną. Ta tabela jest po prostu statyczną tablicą, którą kompilator konfiguruje w czasie kompilacji. Tabela wirtualna zawiera jeden wpis dla każdej funkcji wirtualnej, którą mogą wywołać obiekty tej klasy. Każdy wpis w tej tabeli jest po prostu wskaźnikiem funkcji, który wskazuje na najczęściej pochodną funkcję dostępną dla tej klasy.

Po drugie, kompilator dodaje również ukryty wskaźnik będący członkiem klasy bazowej, który nazwiemy *__vptr. *__vptr jest ustawiany (automatycznie) podczas tworzenia obiektu klasy tak, aby wskazywał na wirtualną tabelę tej klasy. W przeciwieństwie do wskaźnika this , który w rzeczywistości jest parametrem funkcji używanym przez kompilator do rozpoznawania odwołań do siebie, *__vptr jest rzeczywistym elementem wskaźnika. W rezultacie powoduje to, że każdy przydzielony obiekt klasy jest większy o rozmiar jednego wskaźnika. Oznacza to również, że *__vptr jest dziedziczony przez klasy pochodne, co jest ważne.

Do tej pory prawdopodobnie nie wiesz, jak te rzeczy do siebie pasują, więc spójrzmy na prosty przykład:

class Base
{
public:
    virtual void function1() {};
    virtual void function2() {};
};

class D1: public Base
{
public:
    void function1() override {};
};

class D2: public Base
{
public:
    void function2() override {};
};

Ponieważ są tu 3 klasy, kompilator utworzy 3 wirtualne tabele: jedną dla Base, jedną dla D1 i jedną dla D2.

Kompilator dodaje także ukryty wskaźnik do najbardziej podstawowej klasy korzystającej z funkcji wirtualnych. Chociaż kompilator robi to automatycznie, umieścimy to w następnym przykładzie, aby pokazać, gdzie został dodany:

class Base
{
public:
    VirtualTable* __vptr;
    virtual void function1() {};
    virtual void function2() {};
};

class D1: public Base
{
public:
    void function1() override {};
};

class D2: public Base
{
public:
    void function2() override {};
};

Gdy tworzony jest obiekt klasy, *__vptr jest ustawiany tak, aby wskazywał wirtualną tabelę dla tej klasy. Na przykład, gdy tworzony jest obiekt typu Base, *__vptr jest ustawiany tak, aby wskazywał wirtualną tabelę dla Base. Kiedy budowane są obiekty typu D1 lub D2, *__vptr jest ustawiany tak, aby wskazywał wirtualną tabelę odpowiednio dla D1 lub D2.

Porozmawiajmy teraz o tym, jak wypełniane są te wirtualne tabele. Ponieważ są tu tylko dwie funkcje wirtualne, każda wirtualna tabela będzie miała dwa wpisy (jeden dla funkcji 1() i jeden dla funkcji 2()). Pamiętaj, że kiedy te wirtualne tabele są wypełniane, każdy wpis jest wypełniany najbardziej pochodną funkcją, jaką może wywołać obiekt tego typu klasy.

Wirtualna tabela obiektów Base jest prosta. Obiekt typu Base może uzyskać dostęp tylko do elementów członkowskich Base. Baza nie ma dostępu do funkcji D1 i D2. W rezultacie wpis dotyczący funkcji 1 wskazuje na Base::function1(), a wpis dotyczący funkcji 2 wskazuje na Base::function2().

Wirtualna tabela dla D1 jest nieco bardziej złożona. Obiekt typu D1 może uzyskać dostęp do elementów członkowskich zarówno D1, jak i Base. Jednak D1 zastąpiło funkcję 1(), czyniąc D1::funkcja1() bardziej pochodną niż Base::funkcja1(). W związku z tym wpis dotyczący funkcji 1 wskazuje na D1::funkcja1(). D1 nie nadpisał funkcji 2(), zatem wpis dotyczący funkcji 2 będzie wskazywał Base::function2().

Wirtualna tabela dla D2 jest podobna do D1, z tą różnicą, że wpis dotyczący funkcji 1 wskazuje na Base::funkcja1(), a wpis dotyczący funkcji 2 wskazuje na D2::funkcja2().

Oto zdjęcie tego w formie graficznej:

Chociaż ten diagram wygląda trochę szalenie, jest naprawdę całkiem prosty: *__vptr w każdej klasie wskazuje na wirtualną tabelę dla tej klasy. Wpisy w tabeli wirtualnej wskazują na najbardziej pochodną wersję funkcji, którą mogą wywoływać obiekty tej klasy.

Zastanówmy się więc, co się stanie, gdy utworzymy obiekt typu D1:

int main()
{
    D1 d1 {};
}

Ponieważ d1 jest obiektem D1, d1 ma swój *__vptr ustawiony na wirtualną tabelę D1.

Teraz ustawmy wskaźnik bazowy na D1:

int main()
{
    D1 d1 {};
    Base* dPtr = &d1;

    return 0;
}

Należy zauważyć, że ponieważ dPtr jest wskaźnikiem bazowym, wskazuje tylko część bazową d1. Jednak pamiętaj też o tym *__vptr znajduje się w części Base klasy, więc dPtr ma dostęp do tego wskaźnika. Na koniec zauważ to dPtr->__vptr wskazuje na wirtualny stół D1! W związku z tym, mimo że dPtr jest typu Base*, nadal ma dostęp do wirtualnej tabeli D1 (poprzez __vptr).

Co się więc stanie, gdy spróbujemy wywołać funkcję dPtr->function1()?

int main()
{
    D1 d1 {};
    Base* dPtr = &d1;
    dPtr->function1();

    return 0;
}

Po pierwsze, program rozpoznaje, że funkcja1() jest funkcją wirtualną. Po drugie, program wykorzystuje dPtr->__vptr aby dostać się do wirtualnego stołu D1. Po trzecie, sprawdza, którą wersję funkcji 1() należy wywołać w wirtualnej tabeli D1. Zostało to ustawione na D1::function1(). Dlatego, dPtr->function1() rozwiązuje się do D1::function1()!

Teraz możesz powiedzieć: "Ale co by było, gdyby dPtr rzeczywiście wskazywał na obiekt Base zamiast na obiekt D1. Czy nadal wywoływałby D1::function1()?". Odpowiedź brzmi: nie.

int main()
{
    Base b {};
    Base* bPtr = &b;
    bPtr->function1();

    return 0;
}

W tym przypadku, gdy tworzone jest b, b.__vptr wskazuje na wirtualną tabelę Base, a nie na wirtualną tabelę D1. Ponieważ bPtr wskazuje na b, bPtr->__vptr wskazuje również na wirtualny stół Base. Wpis w wirtualnej tabeli Base dla funkcji 1() wskazuje na Base::funkcja1(). Zatem, bPtr->function1() rozwiązuje funkcję Base::function1(), która jest najbardziej pochodną wersją funkcji Function1(), którą obiekt Base powinien móc wywołać.

Korzystając z tych tabel, kompilator i program są w stanie zapewnić, że wywołania funkcji zostaną rozwiązane do odpowiedniej funkcji wirtualnej, nawet jeśli używasz tylko wskaźnika lub odwołania do klasy bazowej!

Wywołanie funkcji wirtualnej jest wolniejsze niż wywołanie funkcji niewirtualnej z kilku powodów: Po pierwsze, musimy użyć metody *__vptr aby dostać się do odpowiedniego wirtualnego stołu. Po drugie, musimy zindeksować wirtualną tabelę, aby znaleźć odpowiednią funkcję do wywołania. Dopiero wtedy możemy wywołać funkcję. W rezultacie musimy wykonać 3 operacje, aby znaleźć funkcję do wywołania, w przeciwieństwie do 2 operacji w przypadku normalnego pośredniego wywołania funkcji lub jednej operacji w przypadku bezpośredniego wywołania funkcji. Jednak w przypadku nowoczesnych komputerów ten dodatkowy czas jest zwykle dość nieistotny.

Gwoli przypomnienia, każda klasa korzystająca z funkcji wirtualnych ma *__vptr i dlatego każdy obiekt tej klasy będzie większy o jeden wskaźnik. Funkcje wirtualne są potężne, ale wiążą się z kosztem wydajności.

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
370 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze