18.6 – Виртуальная таблица (таблица виртуальных функций)
Для реализации виртуальных функций 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()
.
Ниже показано графическое представление этих виртуальных таблиц:
Хотя эта диаграмма выглядит безумной, на самом деле она довольно проста: *__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
, и поэтому размер каждого объекта этого класса будет больше на размер одного указателя. Виртуальные функции – это мощный инструмент, но они требуют дополнительных затрат производительности.