Традиционно компиляторы реализуют вызовы виртуальных функций через двойную косвенную адресацию — если класс содержит хотя бы одну виртуальную функцию, то в начале каждого объекта этого класса хранится адрес таблицы виртуальных функций. Если компилятор не знает конкретный тип объекта, на который указывает указатель, то для вызова виртуальной функции нужно сначала взять указатель на объект, прочитать адрес начала таблицы, затем по номеру метода прочитать адрес, где хранится реализация функции, затем вызвать функцию.
Процесс поиска конкретной функции по указателю на объект называется поздним связыванием и выполняется во время работы программы. Позднее связывание не только увеличивает накладные расходы на вызов, но и препятствует оптимизации кода компилятором. Из-за этого сами виртуальные функции принято считать замедляющими работу.
В тексте выше ключевое слово «если». Что, если компилятор знает, какую функцию на самом деле надо вызывать?
В Стандарте (далее ссылки на Стандарт C++03) ничего не сказано про таблицы виртуальных методов. Вместо этого в 5.2.2/1 ([expr.call], «вызов функции») сказано, что если в программе содержится вызов виртуальной функции, то должна быть вызвана соответствующая функция, выбранная по правилам из 10.3/2 ([class.virtual], «виртуальные функции»), а там сказано, что TL;DR; должна быть выбрана функция из самого производного класса, в котором функция определена или переопределена.
Соответственно, если компилятор может, разобрав код, выяснить точный тип объекта, он не обязан использовать позднее связывание – и не важно, вызывается метод у конкретного объекта, по ссылке или по указателю на объект.
От бессмысленных рассуждений™ перейдем к коду, который будем пробовать на gcc.godbolt.org
Нам понадобятся вот эти два класса:
class Base {
public:
virtual ~Base() {}
virtual int Magic() {
return 9000;
}
};
class Derived : public Base {
public:
virtual int Magic() {
return 100500;
}
};
Для начала такой код:
int main()
{
Derived derived;
return derived.Magic();
}
clang 3.4.1 с -O2 отвечает на это так:
main: # @main
movl $100500, %eax # imm = 0x18894
ret
Нетрудно видеть, что машинный код соответствует программе, содержащей только return 100500; Это не особенно интересно — ведь здесь нет указателей и ссылок.
Ладно, медленно помешивая, добавляем указатели и ссылки:
int magic( Base& object )
{
return object.Magic();
}
int main()
{
Base* base = new Derived();
int result = magic( *base );
delete base;
return result;
}
clang 3.4.1 с -O2 отвечает на это так:
magic(Base&): # @magic(Base&)
movq (%rdi), %rax
jmpq *(%rax) # TAILCALL
main: # @main
movl $100500, %eax # imm = 0x18894
ret
ОХ ЩИ ОШИБКА В КОМПИЛЯТОРЕ Нет, с компилятором все в порядке, но агрессивность оптимизации отрицать бессмысленно. Снова return 100500;
Для сравнения, gcc 4.9.0 с -O2:
main:
subq $8, %rsp
movl $8, %edi
call operator new(unsigned long)
movq vtable for Derived+16, (%rax)
movq %rax, %rdi
call Derived::~Derived()
movl $100500, %eax
addq $8, %rsp
ret
call Derived::~Derived() – из-за виртуального деструктора, gcc в таких случаях ставит вызов ::operator delete() внутрь деструктора:
Derived::~Derived():
jmp operator delete(void*)
хотя мог бы и по месту подставить. Вот так:
movq %rax, %rdi
call operator delete(void*)
Мог бы, но не стал. В то же время тело метода Derived::Magic() подставлено в место вызова и оптимизировано вместе с окружающим кодом.
Небольшое отступление… Если вы любите пространно рассуждать о том, насколько хорошо компилятор в принципе может оптимизировать код, пример выше для вас. И вызов Derived::Magic(), и удаление объекта компилятор мог оптимизировать одинаково успешно, но один из них он оптимизировал, а второй – нет. Отступление закончено.
Для сравнения, gcc 4.9.0 с -O1
magic(Base&):
subq $8, %rsp
movq (%rdi), %rax
call *(%rax)
addq $8, %rsp
ret
main:
pushq %rbp
pushq %rbx
subq $8, %rsp
movl $8, %edi
call operator new(unsigned long)
movq %rax, %rbx
movq vtable for Derived+16, (%rax)
movq %rax, %rdi
call magic(Base&)
movl %eax, %ebp
testq %rbx, %rbx
je .L12
movq (%rbx), %rax
movq %rbx, %rdi
call *16(%rax)
.L12:
movl %ebp, %eax
addq $8, %rsp
popq %rbx
popq %rbp
ret
Вот, может ведь, если хорошо попросить. В этом коде «все в порядке» — куча доступов к памяти и вызов метода инструкцией call с косвенной адресацией (call *16(%rax)).
Впрочем, примеры успеха с -O2 выглядят надуманными – весь код находится в одной единице трансляции, а это существенно упрощает оптимизацию.
На помощь спешит LTO (или как там называется оптимизация нескольких единиц трансляции в вашем компиляторе).
Делим код на несколько единиц трансляции…
//Classes.h
class Base {
public:
virtual int Magic();
virtual ~Base();
};
class Derived : public Base {
public:
virtual int Magic();
};
//Classes.cpp
#include <Classes.h>
#include <stdio.h>
Base::~Base()
{
}
int Base::Magic()
{
return 9000;
}
int Derived::Magic()
{
return 100500;
}
//main.cpp
#include <Classes.h>
int magic( Base& object )
{
return object.Magic();
}
int main()
{
Base* base = new Derived();
int result = magic( *base );
delete base;
return result;
}
Здесь и далее будем использовать MinGW с gcc 4.9.0
g++ -flto -g -O3 main.cpp Classes.cpp
objdump -d -M intel -S --no-show-raw-insn a.exe >a.txt
int main()
{
402830: push ebp
402831: mov ebp,esp
402833: and esp,0xfffffff0
402836: sub esp,0x10
402839: call 402050 <___main>
Base* base = new Derived();
40283e: mov DWORD PTR [esp],0x4
вызов ::operator new()
402845: call 4015d8 <__Znwj>
запись указателя на vtable
40284a: mov DWORD PTR [eax],0x404058
int result = magic( *base );
delete base;
402850: mov ecx,eax
402852: call 4015c0 <__ZN7DerivedD0Ev>
return result;
}
на место возвращаемого значения записывается константа
402857: mov eax,0x18894
40285c: leave
40285d: ret
Здесь нас интересует инструкция mov eax, 0x18894 (100500 в шестнадцатеричной записи) — снова компилятор выбрал нужную функцию, подставил ее тело в место вызова и оптимизировал окружающий код.
Слишком просто, поэтому добавляем фабрику (Derived и Base те же)…
//Factory.h
#include <Classes.h>
class Factory {
public:
static Base* CreateInstance();
};
//Factory.cpp
#include <Factory.h>
Base* Factory::CreateInstance()
{
return new Derived();
}
//main.cpp
#include <Factory.h>
int magic( Base& object )
{
return object.Magic();
}
int main()
{
Base* base = Factory::CreateInstance();
int result = magic( *base );
delete base;
return result;
}
Компилируем, дизассемблируем… Исходно результат выглядит не очень понятно™ – из-за агрессивной оптимизации машинный код и исходный код оказались сопоставлены не самым удобным для чтения образом, ниже машинный код оставлен как есть, а часть строк исходного кода размещена максимально близко к соответствующему машинному коду.
int main()
{
402830: push ebp
402831: mov ebp,esp
402833: push esi
402834: push ebx
402835: and esp,0xfffffff0
402838: sub esp,0x10
40283b: call 402050 <___main>
return new Derived();
402840: mov DWORD PTR [esp],0x4
вызов ::operator new()
402847: call 4015d8 <__Znwj>
40284c: mov ebx,eax
int magic( Base& object )
{
return object.Magic();
40284e: mov ecx,eax
запись указателя на vtable
402850: mov DWORD PTR [eax],0x404058
прямой вызов Derived::Magic()
402856: call 401580 <__ZN7Derived5MagicEv>
int main()
{
delete base;
40285b: mov ecx,ebx
40285d: mov esi,eax
40285f: call 4015b0 <__ZN7DerivedD0Ev>
return result;
402864: lea esp,[ebp-0x8]
402867: mov eax,esi
402869: pop ebx
40286a: pop esi
40286b: pop ebp
40286c: ret
(дальше пропущено)
Здесь нас интересует строка
402856: call 401580 <__ZN7Derived5MagicEv>
Это прямой вызов Derived::Magic():
00401580 <__ZN7Derived5MagicEv>:
int Derived::Magic()
{
return 100500;
}
401580: mov eax,0x18894
401585: ret
Компилятор правильно определил, какую функцию нужно вызвать, но не стал подставлять тело функции в место вызова.
Параметризуем фабрику (Base и Derived те же)…
//Factory.h
#include <Classes.h>
enum ClassType {
BaseType,
DerivedType
};
class Factory {
public:
static Base* CreateInstance(ClassType classType);
};
//Factory.cpp
#include <Factory.h>
Base* Factory::CreateInstance(ClassType classType)
{
switch( classType ) {
case BaseType:
return new Base();
case DerivedType:
return new Derived();
}
}
//main.cpp
#include <Factory.h>
int magic( Base& object )
{
return object.Magic();
}
int main()
{
Base* base = Factory::CreateInstance(DerivedType);
int result = magic( *base );
delete base;
return result;
}
Получаем… тот же код, что и в предыдущей попытке.
Теперь party hard будем вычислять параметр фабрики при работе программы…
#include <Factory.h>
#include <cstdlib>
int magic( Base& object )
{
return object.Magic();
}
int main()
{
Base* base = Factory::CreateInstance(rand() ? BaseType : DerivedType);
int result = magic( *base );
delete base;
return result;
}
Получаем… (результат опять выглядит не очень понятно)
int main()
{
402830: push ebp
402831: mov ebp,esp
402833: push esi
402834: push ebx
402835: and esp,0xfffffff0
402838: sub esp,0x10
40283b: call 402050 <___main>
Base* base = Factory::CreateInstance(rand() ? BaseType : DerivedType);
вызов rand()
402840: call 4027c8 <_rand>
Base* Factory::CreateInstance(ClassType classType)
{
switch( classType ) {
проверка из объединенных вместе тернарного оператора и switch
402845: test eax,eax
402847: mov DWORD PTR [esp],0x4
ветвление
40284e: jne 402875 <_main+0x45>
если rand() вернула не ноль, происходит переход вперед на адрес 402875
если rand() вернула ноль, перехода нет и ...
case DerivedType:
return new Derived();
вызов ::operator new()
402850: call 4015d8 <__Znwj>
запись указателя на vtable класса Derived
402855: mov DWORD PTR [eax],0x404070
40285b: mov ebx,eax
int magic( Base& object )
{
return object.Magic();
здесь происходит слияние двух ветвей -
управление либо "проваливается" сюда, либо безусловно
приходит из ветви, начинающейся с адреса 402875 (rand() != 0)
40285d: mov eax,DWORD PTR [ebx]
40285f: mov ecx,ebx
косвенный вызов Magic()
402861: call DWORD PTR [eax]
402863: mov esi,eax
int main()
{
delete base;
402865: mov eax,DWORD PTR [ebx]
402867: mov ecx,ebx
косвенный вызов удаления объекта
402869: call DWORD PTR [eax+0x8]
return result;
}
40286c: lea esp,[ebp-0x8]
40286f: mov eax,esi
402871: pop ebx
402872: pop esi
402873: pop ebp
402874: ret
Base* Factory::CreateInstance(ClassType classType)
{
switch( classType ) {
case BaseType:
return new Base();
сюда управление приходит при ветвлении по адресу 40284e
если rand() != 0
вызов ::operator new()
402875: call 4015d8 <__Znwj>
запись указателя на vtable класса Base
40287a: mov DWORD PTR [eax],0x404058
402880: mov ebx,eax
окончание ветви и безусловный переход в точку слияния
402882: jmp 40285d <_main+0x2d>
Довольно любопытный результат. Код метода фабрики полностью подставлен по месту. В зависимости от результата вызова функции rand() прямо внутри main() выполняется ветвление и создание экземпляров соответствующих классов. Компилятор мог бы дальше поставить и прямые вызовы в каждой из ветвей, но не справился с оптимизацией и скатился в два косвенных вызова – один для вызова метода Magic() с поздним связыванием, второй – для удаления объекта, тоже с поздним связыванием.
Как видим, вызовы виртуальных функций не обязывают использовать позднее связывание, а что произойдет в реальном мире – зависит от компилятора, его настроек и конкретного кода.
Дмитрий Мещеряков,
департамент продуктов для разработчиков
Автор: DmitryMe