C++ MythBusters. Миф о виртуальных функциях

Posted: пятница, 6 февраля 2009 г.

Здравствуйте. В прошлой статье я рассказывал, с какой не всем известной особенностью можно столкнуться при работе с подставляемыми функциями. На Хабрахабре статья породила как несколько существенных замечаний, так и многостраничные споры (и даже холивары), начавшиеся с того, что inline-функции вообще лучше не использовать, и перешедшие в стандартную тему C vs. C++ vs. Java vs. C# vs. PHP vs. Haskell vs. …

Сегодня пришла очередь виртуальных функций. И, во-первых, я сразу оговорюсь, что статья моя (в принципе, как и предыдущая) ни в коей мере не претендует на полноту изложения. А во-вторых, как и раньше, эта статья не для профессионалов. Она будет полезна тем, кто уже нормально разбирается в основах C++, но имеет недостаточно опыта, либо же тем, кто не любит читать книжек.

Надеюсь, все знают, что такое виртуальные функции и как они используются, так как объяснять это уже не моя задача. Если в материале про inline-методы миф был не совсем очевиден, то в этом — напротив. Собственно, перейдем к «мифу».

Виртуальные функции и ключевое слово virtual

К моему удивлению, я очень часто сталкивался и сталкиваюсь с людьми (да что там говорить, я и сам был таким же), которые считают, что ключевое слово virtual делает функцию виртуальной только на один уровень иерархии. Объясню, что имеется в виду, на примере:
#include <cstdlib>
#include <iostream> 
  
using std::cout; 
using std::endl; 
  
struct A 
{ 
	virtual ~A() {} 
  
	virtual void foo() const { cout << "A::foo()" << endl; } 
	virtual void bar() const { cout << "A::bar()" << endl; } 
	void baz() const { cout << "A::baz()" << endl; } 
}; 
  
struct B : public A 
{ 
	virtual void foo() const { cout << "B::foo()" << endl; } 
	void bar() const { cout << "B::bar()" << endl; } 
	void baz() const { cout << "B::baz()" << endl; } 
}; 
  
struct C : public B 
{ 
	virtual void foo() const { cout << "C::foo()" << endl; } 
	virtual void bar() const { cout << "C::bar()" << endl; } 
	void baz() const { cout << "C::baz()" << endl; } 
}; 
  
int main() 
{ 
	cout << "pA is B:" << endl; 
	A * pA = new B; 
	pA->foo(); 
	pA->bar(); 
	pA->baz(); 
	delete pA; 
	
	cout << "\npA is C:" << endl; 
	pA = new C; 
	pA->foo(); pA->bar(); pA->baz(); 
	delete pA; 
  
	return EXIT_SUCCESS; 
}

 

Итак, имеем простую иерархию классов. В каждом классе определены 3 метода: foo(), bar() и baz(). Рассмотрим неверную логику людей, которые находятся под действием мифа: когда указатель pA указывает на объект типа B имеем вывод:

pA is B:

B::foo() // потому что в родительском классе A метод foo() помечен как virtual

B::bar() // потому что в родительском классе A метод bar() помечен как virtual

A::baz() // потому что в родительском классе A метод baz() не помечен как virtual

когда указатель pA указывает на объект типа С имеем вывод:

pA is C:

С::foo() // потому что в родительском классе B метод foo() помечен как virtual

B::bar() // потому что в родительском классе B метод bar() не помечен как virtual,

            // но он помечен как virtual в классе A, указатель на который мы используем

A::baz() // потому что в классе A метод baz() не помечен как virtual

С невиртуальной функцией baz() всё и так ясно. А вот с логикой вызова виртуальных функций есть неувязочка. Думаю, не стоит говорить, что на самом деле вывод будет следующим:

pA is B:

B::foo()

B::bar()

A::baz()

 

pA is C:

C::foo()

C::bar()

A::baz()

Вывод: виртуальная функция становится виртуальной до конца иерархии, а ключевое слово virtual является «ключевым» только в первый раз, а в последующие разы оно несет в себе чисто информативную функцию для удобства программистов.

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

Раннее и позднее связывание. Таблица виртуальных функций

Связывание — это сопоставление вызова функции с вызовом. В C++ все функции по умолчанию имеют раннее связывание, то есть компилятор и компоновщик решают, какая именно функция должна быть вызвана, до запуска программы. Виртуальные функции имеют позднее связывание, то есть при вызове функции нужное тело выбирается на этапе выполнения программы.

Встретив ключевое слово virtual, компилятор помечает, что для этого метода должно использоваться позднее связывание: для начала он создает для класса таблицу виртуальных функций, а в класс добавляет новый скрытый для программиста член — указатель на эту таблицу. (На самом деле, насколько я знаю, стандарт языка не предписывает, как именно должен быть реализован механизм виртуальных функций, но реализация на основе виртуальной таблицы стала стандартом де факто.). Рассмотрим этот пруфкод:

#include <cstdlib>
#include <iostream> 
 
struct Empty {};
 
struct EmptyVirt { virtual ~EmptyVirt(){} };
 
struct NotEmpty { int m_i; };
 
struct NotEmptyVirt
{
	virtual ~NotEmptyVirt() {}
	int m_i;
};
 
struct NotEmptyNonVirt
{
	void foo() const {}
	int m_i;
};
 
int main()
{
	std::cout << sizeof(Empty) << std::endl;
	std::cout << sizeof(EmptyVirt) << std::endl;
	std::cout << sizeof(NotEmpty) << std::endl;
	std::cout << sizeof(NotEmptyVirt) << std::endl;
	std::cout << sizeof(NotEmptyNonVirt) << std::endl;
	
	return EXIT_SUCCESS;
}

Вывод может отличаться в зависимости от платформы, но в моем случае (Win32, msvc2008) он был следующим:

1

4

4

8

4

Что можно понять из этого примера. Во-первых, размер «пустого» класса всегда больше нуля, потому что компилятор специально вставляет в него фиктивный член. Как пишет Эккель, «представьте процесс индексирования в массиве объектов нулевого размера, и все станет ясно» ;) Во-вторых, мы видим, что размер «непустого» класса NotEmptyVirt при добавлении в него виртуальной функции увеличился на стандартный размер указателя на void; а в «пустом» классе EmptyVirt фиктивный член, который компилятор ранее добавлял для приведения класса к ненулевому размеру, был заменен на указатель. В то же время добавление невиртуальной функции в класс на размер не влияет (спасибо nullbie за совет). Имя указателя на таблицу отличается в зависимости от компилятора. К примеру, компилятор Visual Studio 2008 называет его __vfptr, а саму таблицу ‘vftable’ (кто не верит, может посмотреть в отладчике :) В литературе указатель на таблицу виртуальных функций принято называть VPTR, а саму таблицу VTABLE, поэтому я буду придерживаться таких же обозначений.

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

Таблиц виртуальных функций у нас будет столько, сколько есть классов, содержащих виртуальные функции — по одной таблице на класс. Объекты каждого из классов содержат именно указатель на таблицу, а не саму таблицу! Вопросы на эту тему любят задавать преподаватели, а также те, кто проводит собеседования. (Примеры каверзных вопросов, на которых можно подловить новичков: «если класс содержит таблицу виртуальных функций, то размер объекта класса будет зависеть от количества виртуальных функций, содержащихся в нем, верно?»; «имеем массив указателей на базовый класс, каждый из которых указывает на объект одного из производных классов — сколько у нас будет таблиц виртуальных функций?» и т.д.).

Итак, для каждого класса у нас будет создана таблица виртуальных функций. Каждой виртуальной функции базового класса присваивается подряд идущий индекс (в порядке объявления функций), по которому в последствие и будет определяться адрес тела функции в таблице VTABLE. При наследовании базового класса, производный класс «получает» и таблицу адресов виртуальных функций базового класса. Если какой-либо виртуальный метод в производном классе переопределяется, то в таблице виртуальных функций этого класса адрес тела соответствующего метода просто будет заменен на новый. При добавлении в производный класс новых виртуальных методов VTABLE производного класса расширяется, а таблица базового класса естественно остается такой же, как и была. Поэтому через указатель на базовый класс нельзя виртуально вызвать методы производного класса, которых не было в базовом — ведь базовый класс о них ничего «не знает» (дальше мы все это посмотрим на примере).

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

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

Из всего вышесказанного можно сделать вывод, что механизм позднего связывания требует дополнительных затрат процессорного времени (инициализация VPTR конструктором, получение адреса функции при вызове) по сравнению с ранним.

Думаю, на примере все станет понятнее. Рассмотрим следующую иерархию:

В данном случае получим две таблицы виртуальных функций:

Base

0 Base::foo()
1 Base::bar()
2 Base::baz()
и

Inherited

0 Base::foo()
1 Inherited::bar()
2 Base::baz()
3 Inherited::qux()

 

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

#include <cstdlib>
#include <iostream> 
 
using std::cout;
using std::endl;
 
struct Base
{
	Base() { cout << "Base::Base()" << endl; }
	virtual ~Base() { cout << "Base::~Base()" << endl; }
	
	virtual void foo() { cout << "Base::foo()" << endl; }
	virtual void bar() { cout << "Base::bar()" << endl; }
	virtual void baz() { cout << "Base::baz()" << endl; }
};
 
struct Inherited : public Base
{
	Inherited() { cout << "Inherited::Inherited()" << endl; }
	virtual ~Inherited() { cout << "Inherited::~Inherited()" << endl; }

	virtual void bar() { cout << "Inherited::bar()" << endl; }
	virtual void qux() { cout << "Inherited::qux()" << endl; }
};
 
int main()
{
	Base * pBase = new Inherited;
	pBase->foo();
	pBase->bar();
	pBase->baz();
	//pBase->qux();    // Ошибка
	delete pBase;
 
	return EXIT_SUCCESS;
}

Что происходит при запуске программы? Вначале объявляем указатель на объект типа Base, которому присваиваем адрес вновь созданного объекта типа Inherited. При этом вызывается конструктор Base, инициализирует VPTR адресом VTABLE класса Base, а затем конструктор Inherited, который перезаписывает значение VPTR адресом VTABLE класса Inherited. При вызове pBase->foo(), pBase->bar() и pBase->baz() компилятор через указатель VPTR достает фактический адрес тела функции из таблицы виртуальных функций. Как это происходит? Вне зависимости от конкретного типа объекта компилятор знает, что адрес функции foo() находится на первом месте, bar() — на втором, и т.д. (как я и говорил, в порядке объявления функций). Таким образом, для вызова, к примеру, функции baz() он получает адрес функции в виде VPTR+2 — смещение от начала таблицы виртуальных функций, сохраняет этот адрес и подставляет в команду call. По этой же причине, вызов pBase->qux() приводит к ошибке: несмотря на то, что фактический тип объекта Inherited, когда мы присваиваем его адрес указателю на Base, происходит восходящее приведение типа, а в таблице VTABLE класса Base никакого четвертого метода нет, поэтому VPTR+3 указывало бы на «чужую» память (к счастью, такой код даже не компилируется).

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

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

Base * pBase = new Inherited;

происходит повышающее приведение типа: Inherited* приводится к Base*, но в любом случае указатель всего лишь хранит адрес «начала» объекта в памяти. Если же повышающее приведение производить непосредственно для объекта, то он фактически «обрезается» до размера объекта базового класса. Поэтому логично, что для вызова функций «через объект» используется раннее связывание — компилятор и так «знает» фактический тип объекта.

Собственно, это всё. Жду комментариев. Спасибо за внимание.

P.S. Данная статья помечена грифом «Гарантия Скора» ©

(Skor, если ты это читаешь, это для тебя ;)

Progg it

blog comments powered by Disqus