
Процесс поиска конкретной функции по указателю на объект называется поздним связыванием и выполняется во время работы программы. Позднее связывание не только увеличивает накладные расходы на вызов, но и препятствует оптимизации кода компилятором. Из-за этого сами виртуальные функции принято считать замедляющими работу.
В тексте выше ключевое слово «если». Что, если компилятор знает, какую функцию на самом деле надо вызывать?
В Стандарте (далее ссылки на Стандарт 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
Для сравнения, 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;
}
Получаем… тот же код, что и в предыдущей попытке.
Теперь
#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() с поздним связыванием, второй – для удаления объекта, тоже с поздним связыванием.
Как видим, вызовы виртуальных функций не обязывают использовать позднее связывание, а что произойдет в реальном мире – зависит от компилятора, его настроек и конкретного кода.
Дмитрий Мещеряков,
департамент продуктов для разработчиков