18.6 – Виртуальная таблица (таблица виртуальных функций)

Добавлено 19 августа 2021 в 00:51

Для реализации виртуальных функций C++ использует специальную форму позднего связывания, известную как виртуальная таблица. Виртуальная таблица – это таблица поиска функций, используемая для разрешения вызовов функций в режиме динамического/позднего связывания. Виртуальную таблицу иногда также называют, как «vtable», «таблица виртуальных функций», «таблица виртуальных методов» или «таблица диспетчеризации».

Поскольку знание того, как работает виртуальная таблица, не обязательно для использования виртуальных функций, этот раздел можно считать необязательным для прочтения.

Виртуальная таблица на самом деле довольно проста, хотя описать словами ее немного сложно. Во-первых, каждому классу, использующему виртуальные функции (или производному от класса, использующего виртуальные функции), предоставляется собственная виртуальная таблица. Эта таблица представляет собой просто статический массив, который компилятор создает и заполняет во время компиляции. Виртуальная таблица содержит по одной записи для каждой виртуальной функции, которая может быть вызвана объектами класса. Каждая запись в этой таблице – это просто указатель на функцию, указывающий на наиболее производную версию функции, доступную для данного класса.

Во-вторых, компилятор также добавляет скрытый указатель, который является членом базового класса, который мы назовем *__vptr. *__vptr устанавливается (автоматически) при создании экземпляра класса, и он указывает на виртуальную таблицу для этого класса. В отличие от указателя *this, который на самом деле является параметром функции, используемым компилятором для вычисления ссылок на себя, *__vptr является реальным указателем. Следовательно, он увеличивает размер каждого размещаемого объекта класса на размер одного указателя. Это также означает, что *__vptr наследуется производными классами, что важно.

К настоящему времени вы, вероятно, не понимаете, как всё это сочетается друг с другом, поэтому давайте рассмотрим простой пример:

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

Поскольку здесь есть 3 класса, компилятор создаст 3 виртуальные таблицы: одну для Base, одну для D1 и одну для D2.

Компилятор также добавляет член скрытого указателя к самому базовому классу, который использует виртуальные функции. Хотя компилятор делает это автоматически, покажем в следующем примере, куда он добавляется:

class Base
{
public:
    FunctionPointer* __vptr;
    virtual void function1() {};
    virtual void function2() {};
};
 
class D1: public Base
{
public:
    virtual void function1() {};
};
 
class D2: public Base
{
public:
    virtual void function2() {};
};

Когда создается объект класса, *__vptr устанавливается так, чтобы указывать на виртуальную таблицу этого класса. Например, когда создается объект типа Base, *__vptr устанавливается так, чтобы указывать на виртуальную таблицу для Base. Когда создаются объекты типа D1 или D2, *__vptr устанавливается так, чтобы указывать на виртуальную таблицу для D1 или D2 соответственно.

Теперь поговорим о том, как заполняются эти виртуальные таблицы. Поскольку здесь всего две виртуальные функции, каждая виртуальная таблица будет иметь две записи (одну для function1() и одну для function2()). Помните, что при заполнении этих виртуальных таблиц каждая запись заполняется наиболее производной версией функции, которую может вызвать объект этого класса.

Виртуальная таблица для объектов Base проста. Объект типа Base может получить доступ только к членам Base. Base не имеет доступа к функциям D1 или D2. Следовательно, запись для function1 указывает на Base::function1(), а запись для function2 указывает на Base::function2().

Виртуальная таблица для D1 немного сложнее. Объект типа D1 может иметь доступ к членам как D1, так и Base. Однако D1 переопределил function1(), что сделало D1::function1() более производной версией, чем Base::function1(). Следовательно, запись для function1 указывает на D1::function1(). D1 не переопределил function2(), поэтому запись для function2 будет указывать на Base::function2().

Виртуальная таблица для D2 похожа на D1, за исключением того, что запись для function1 указывает на Base::function1(), а запись для function2 указывает на D2::function2().

Ниже показано графическое представление этих виртуальных таблиц:

Рисунок 1 – Виртуальные таблицы классов Base, D1 и D2
Рисунок 1 – Виртуальные таблицы классов Base, D1 и D2

Хотя эта диаграмма выглядит безумной, на самом деле она довольно проста: *__vptr в каждом классе указывает на виртуальную таблицу для этого класса. Записи в виртуальной таблице указывают на наиболее производные версии функциональных объектов этого класса, которые им разрешено вызывать.

Итак, рассмотрим, что происходит, когда мы создаем объект типа D1:

int main()
{
    D1 d1;
}

Поскольку d1 является объектом D1, d1 содержит *__vptr, указывающий на виртуальную таблицу D1.

Теперь давайте установим указатель типа Base на объект D1:

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

Обратите внимание: поскольку dPtr является указателем типа Base, он указывает только на часть Base в d1. Однако также обратите внимание, что *__vptr находится в части Base класса, поэтому dPtr имеет доступ к этому указателю. Наконец, обратите внимание, что dPtr->__vptr указывает на виртуальную таблицу D1! Следовательно, даже если dPtr имеет тип Base, он всё еще имеет доступ к виртуальной таблице D1 (через __vptr).

Так что же происходит, когда мы пытаемся вызвать dPtr->function1()?

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

Во-первых, программа распознает, что function1() является виртуальной функцией. Во-вторых, программа использует dPtr->__vptr для доступа к виртуальной таблице D1. В-третьих, в виртуальной таблице D1 она ищет версию функции function1() для вызова. Эта версия была установлена в D1::function1(). Следовательно, dPtr->function1() преобразуется в D1::function1()!

Теперь вы можете сказать: «Но что, если dPtr на самом деле указывает на объект Base, а не на объект D1? Будет ли он по-прежнему вызывать D1::function1()? ». Ответ – нет.

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

В этом случае, когда создается b, __vptr указывает на виртуальную таблицу Base, а не на виртуальную таблицу D1. Следовательно, bPtr->__vptr также будет указывать на виртуальную таблицу Base. Запись в виртуальной таблице Base для function1() указывает на Base::function1(). Таким образом, bPtr->function1() преобразуется в Base::function1(), которая является наиболее производной версией function1(), которую объект Base может вызывать.

Используя эти таблицы, компилятор и программа могут обеспечить разрешение вызовов функции на соответствующую виртуальную функцию, даже если вы используете только указатель или ссылку на базовый класс!

Вызов виртуальной функции происходит медленнее, чем вызов невиртуальной функции, по нескольким причинам: во-первых, мы должны использовать *__vptr, чтобы перейти к соответствующей виртуальной таблице. Во-вторых, мы должны проиндексировать виртуальную таблицу, чтобы найти правильную функцию для вызова. Только тогда мы можем вызвать функцию. В результате нам нужно выполнить 3 операции, чтобы найти функцию для вызова, в отличие от двух операций для обычного косвенного вызова функции или одной операции для прямого вызова функции. Однако на современных компьютерах это дополнительное время обычно довольно незначительно.

Также напомним, что любой класс, использующий виртуальные функции, содержит *__vptr, и поэтому размер каждого объекта этого класса будет больше на размер одного указателя. Виртуальные функции – это мощный инструмент, но они требуют дополнительных затрат производительности.

Теги

C++ / CppLearnCppvtableВиртуальная таблицаВиртуальная функцияДинамическое связываниеДля начинающихОбучениеПозднее связываниеПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.