Я думаю, все знают, что такое наследование или хотя бы слышали о нём. Часто мы используем наследование ради полиморфного поведения объектов. Но задумываемся ли мы о той цене, которую приходится платить за виртуальность? Поставлю вопрос по-другому: каждый ли знает эту цену? Давайте попробуем разобраться в этой проблеме.
В общем случае, выглядит наследование так:
При этом, как мы прекрасно знаем, класс Сhild наследует все члены класса Base. Т.е. с точки зрения размеров объектов, сейчас у нас sizeof(Base) = sizeof(Child) и составляет 4 (поскольку sizeof(int) = 4).
Не помешает сразу напомнить, что такое выравнивание. У нас есть два класса:
Вроде бы они ничем не отличаются друг от друга. Однако их размеры не одинаковы: sizeof(A2) = 16, sizeof(A1) = 24.
Всё дело в расположении переменных внутри класса. Если они имеют разный тип, то их расположение может серьёзно повлиять на размер объекта. В данном случае sizeof(double = 8), т.е 8 + 4 + 4 = 16, но класс A1 при этом имеет больший размер. А всё потому, что:
В итоге мы видим лишние 8 байт, которые добавились из-за того, что double оказался посередине. Во втором же случае картина будет примерно такая:
Но, скорее всего, вы и так это знали.
Теперь давайте вспомним, как мы расплачиваемся за виртуальные функции в классе. Вы, возможно, помните о таблицах виртуальных методов. Стандарт С++ не предусматривает какой-то единой реализации для вычисления адреса функции во время выполнения. Всё сводится к тому, что у нас появляется указатель в каждом классе, где есть хотя бы одна виртуальная функция.
Давайте допишем одну виртуальную функцию классу Base и посмотрим, как изменятся размеры:
Размер стал равным 16. 8 — размер указателя 4 — int плюс выравнивание. В 32-х разрядной архитектуре размер будет равен 8. 4 — указатель + 4 int без выравнивания.
Чтобы вам не приходилось верить на слово, приводим код, который сгенерировал Hopper Disassembler v4:
//исходный код
Ассемблерный код:
Без виртуальной функции ассемблерный код выглядит так:
Можно увидеть, что во втором случае у нас нет записи какого-либо адреса и переменная записывается без смещения на 8 байт.
Для тех, кто не любит ассемблер, давайте выведем, как это примерно будет выглядеть в памяти:
Вывод:
Раскомментим виртуальную функцию и полюбуемся на результат:
Теперь, когда мы это всё вспомнили, поговорим о виртуальном наследовании. Ни для кого не секрет, что в С++ возможно множественное наследование. Это мощная функция, которую лучше не трогать неумелыми руками — это не приведёт ни к чему хорошему. Но не будем о грустном. Самая известная проблема при множественном наследовании — это проблема ромба.
В классе D мы получим дублирующиеся члены класса А. Что в этом плохого? Даже если не брать в расчет, что размер класса увеличится на лишние n байт размера класса А, плохо то, что у нас получаются неоднозначности при вызове функций класса А — непонятно, какие именно вызывать: B::A::func или C::A::func. Мы всегда можем устранить подобные неоднозначности явными вызовами, но это не очень удобно. Вот здесь-то в игру и вступает виртуальное наследование. Чтобы не получать дубликат класса А, мы виртуально наследуемся от него:
Теперь всё хорошо. Или нет? Какой размер будет у класса D, если у нас в классе А всего один виртуальный метод?
Это интересный вопрос, потому тут всё что зависит от компилятора. Например, Visual Studio 2015 с настройками проекта по умолчанию выдаст: 4 8 8 12.
То есть мы имеем 4 байта на указатель в классе А (далее я буду сокращенно обозначать эти указатели, например, vtbA), дополнительно 4 байта на указатель из-за виртуального наследования для класса B и С (vtbB и vtbC). Наконец в D: 8 + 8 — 4, так как vtbA не дублируется, выходит 12.
А вот gcc 4.2.1 выдаст 8 8 8 16.
Давайте рассмотрим сначала случай без виртуального наследования, потому что результат будет таким же.
8 байт на vtbA, в классах B и С хранятся указатели только на виртуальные таблицы этих классов. Получается, что мы дублируем виртуальные таблицы, но зато не надо хранить vtbA в наследниках. В классе D хранится два адреса: для vtbB и vtbC.
Ничего не понятно? Смотрите: мы сохраняем два адреса в 0f95 и 0f98. Рассчитываются они исходя из того адреса, что лежит в 1018, плюс 0x28 в первом случае и 0x10 во втором. Итого мы получаем 10b0 и 10d0.
Теперь рассмотрим случай, когда наследование виртуальное.
В плане ассемблерного кода мало что меняется, у нас также хранится два адреса, но виртуальные таблицы для B, C и D стали значительно больше. Например, таблица для класса D увеличилась более чем в 7 раз!
Сэкономили на размере объекта, но увеличили размеры таблиц. А что если мы будем использовать виртуальное наследование повсюду, как советуют некоторые авторы?
Не приведём уже точных ссылок, но где-то читали, что если допускается мысль о множественном наследовании, то всегда нужно использовать виртуальное наследование, дабы уберечься от дублирования.
Итак, начинаем следовать совету в лоб:
Насколько изменится размер D?
Visual Studio 2015 выведет 4 8 8 16, т. е. добавился еще один указатель в классе D. Путём экспериментов мы выяснили, что, если наследоваться виртуально от каждого класса, то студия добавит еще один указатель в текущий класс. Например, если бы мы написали так:
или так:
то размер остался бы 12 байт.
Не подумайте, что студия экономит память, это вовсе не так. Для стандартных настроек размер указателя 4 байта, а не 8, как в gcc. Так что умножайте результат на 2.
А что gcc 4.2.1? Он вообще не изменит размер объектов, вывод все тот же — 8 8 8 16. Но представляете, что стало с таблицей для D?!
На самом деле, она, конечно, увеличилась, но незначительно. Другой вопрос, как это всё повлияет на последующие иерархии.
В качестве чистого эксперимента (не будем думать, есть ли в этом практическая польза) проверим, что случится с такой иерархией:
В студии размер класса E возрастет на 4, это мы уже выяснили, а в gcc размер D и E составит 16 байт.
Но при этом размер виртуальной таблицы для класса E (а она и так немаленькая, если убрать все виртуальное наследование) возрастёт в 4 раза! Если я правильно всё посчитал, то он уже достигнет половины килобайта или около того.
Какой же вывод можно сделать? Такой же, как и раньше: множественное наследование стоит использовать очень аккуратно, виртуальное наследование не панацея и, так или иначе, мы за него расплачиваемся. Возможно, стоит подумать в сторону интерфейсов и отказаться от виртуального наследования вообще.
В общем случае, выглядит наследование так:
class Base
{
int variable;
};
class Child: public Base
{
};
При этом, как мы прекрасно знаем, класс Сhild наследует все члены класса Base. Т.е. с точки зрения размеров объектов, сейчас у нас sizeof(Base) = sizeof(Child) и составляет 4 (поскольку sizeof(int) = 4).
Не помешает сразу напомнить, что такое выравнивание. У нас есть два класса:
class A1
{
int iv;
double dv;
int iv2;
};
class A2
{
double dv;
int iv;
int iv2;
};
Вроде бы они ничем не отличаются друг от друга. Однако их размеры не одинаковы: sizeof(A2) = 16, sizeof(A1) = 24.
Всё дело в расположении переменных внутри класса. Если они имеют разный тип, то их расположение может серьёзно повлиять на размер объекта. В данном случае sizeof(double = 8), т.е 8 + 4 + 4 = 16, но класс A1 при этом имеет больший размер. А всё потому, что:
В итоге мы видим лишние 8 байт, которые добавились из-за того, что double оказался посередине. Во втором же случае картина будет примерно такая:
Но, скорее всего, вы и так это знали.
Теперь давайте вспомним, как мы расплачиваемся за виртуальные функции в классе. Вы, возможно, помните о таблицах виртуальных методов. Стандарт С++ не предусматривает какой-то единой реализации для вычисления адреса функции во время выполнения. Всё сводится к тому, что у нас появляется указатель в каждом классе, где есть хотя бы одна виртуальная функция.
Давайте допишем одну виртуальную функцию классу Base и посмотрим, как изменятся размеры:
class Base
{
int variable;
virtual void f() {}
};
class Child: public Base
{
};
Размер стал равным 16. 8 — размер указателя 4 — int плюс выравнивание. В 32-х разрядной архитектуре размер будет равен 8. 4 — указатель + 4 int без выравнивания.
Чтобы вам не приходилось верить на слово, приводим код, который сгенерировал Hopper Disassembler v4:
//исходный код
class Base
{
public:
int variable;
virtual void f() {}
Base(): variable(10) {}
};
//в main
Base a;
Ассемблерный код:
; Variables:
; var_8: -8
__ZN4BaseC2Ev: // Base::Base()
0000000100000f70 push rbp ; CODE XREF=__ZN4BaseC1Ev+16
0000000100000f71 mov rbp, rsp
0000000100000f74 mov rax, qword [0x100001000]
0000000100000f7b add rax, 0x10
0000000100000f7f mov qword [rbp+var_8], rdi
0000000100000f83 mov rdi, qword [rbp+var_8]
0000000100000f87 mov qword [rdi], rax
0000000100000f8a mov dword [rdi+8], 0xa
0000000100000f91 pop rbp
0000000100000f92 ret
Без виртуальной функции ассемблерный код выглядит так:
; Variables:
; var_8: -8
__ZN4BaseC2Ev: // Base::Base()
0000000100000fa0 push rbp ; CODE XREF=__ZN4BaseC1Ev+16
0000000100000fa1 mov rbp, rsp
0000000100000fa4 mov qword [rbp+var_8], rdi
0000000100000fa8 mov rdi, qword [rbp+var_8]
0000000100000fac mov dword [rdi], 0xa
0000000100000fb2 pop rbp
0000000100000fb3 ret
Можно увидеть, что во втором случае у нас нет записи какого-либо адреса и переменная записывается без смещения на 8 байт.
Для тех, кто не любит ассемблер, давайте выведем, как это примерно будет выглядеть в памяти:
#include <iostream>
#include <iomanip>
using namespace std;
const int memorysize = 16;
class Base
{
public:
int variable;
//virtual void f() {}
Base(): variable(0xAAAAAAAA) {} //чтобы было видно занятое место этой переменной
};
class Child: public Base
{
};
void PrintMemory(const unsigned char memory[])
{
for (size_t i = 0; i < memorysize / 8; ++i)
{
for (size_t j = 0; j < 8; ++j)
{
cout << setw(2) << setfill('0') << uppercase << hex
<< (int)(memory[i * 8 + j]) << " ";
}
cout << endl;
}
}
int main()
{
unsigned char memory[memorysize];
memset(memory, 0xFF, memorysize * sizeof(unsigned char)); //заполняем память мусором FF
new (memory) Base; //выделяем память на объект и записываем в memory
PrintMemory(memory);
reinterpret_cast<Base *>(memory)->~Base();
return 0;
}
Вывод:
AA AA AA AA FF FF FF FF
FF FF FF FF FF FF FF FF
Раскомментим виртуальную функцию и полюбуемся на результат:
E0 30 70 01 01 00 00 00
AA AA AA AA FF FF FF FF
Теперь, когда мы это всё вспомнили, поговорим о виртуальном наследовании. Ни для кого не секрет, что в С++ возможно множественное наследование. Это мощная функция, которую лучше не трогать неумелыми руками — это не приведёт ни к чему хорошему. Но не будем о грустном. Самая известная проблема при множественном наследовании — это проблема ромба.
class A;
class B: public A;
class C: public A;
class D: public B, public C;
В классе D мы получим дублирующиеся члены класса А. Что в этом плохого? Даже если не брать в расчет, что размер класса увеличится на лишние n байт размера класса А, плохо то, что у нас получаются неоднозначности при вызове функций класса А — непонятно, какие именно вызывать: B::A::func или C::A::func. Мы всегда можем устранить подобные неоднозначности явными вызовами, но это не очень удобно. Вот здесь-то в игру и вступает виртуальное наследование. Чтобы не получать дубликат класса А, мы виртуально наследуемся от него:
class A;
class B: public virtual A;
class C: public virtual A;
class D: public B, public C;
Теперь всё хорошо. Или нет? Какой размер будет у класса D, если у нас в классе А всего один виртуальный метод?
cout << sizeof(A) << " " << sizeof(B) << " "
<< sizeof(C) << " " << sizeof(D) << endl;
Это интересный вопрос, потому тут всё что зависит от компилятора. Например, Visual Studio 2015 с настройками проекта по умолчанию выдаст: 4 8 8 12.
То есть мы имеем 4 байта на указатель в классе А (далее я буду сокращенно обозначать эти указатели, например, vtbA), дополнительно 4 байта на указатель из-за виртуального наследования для класса B и С (vtbB и vtbC). Наконец в D: 8 + 8 — 4, так как vtbA не дублируется, выходит 12.
А вот gcc 4.2.1 выдаст 8 8 8 16.
Давайте рассмотрим сначала случай без виртуального наследования, потому что результат будет таким же.
8 байт на vtbA, в классах B и С хранятся указатели только на виртуальные таблицы этих классов. Получается, что мы дублируем виртуальные таблицы, но зато не надо хранить vtbA в наследниках. В классе D хранится два адреса: для vtbB и vtbC.
0000000100000f7f mov rax, qword [0x100001018]
0000000100000f86 mov rdi, rax
0000000100000f89 add rdi, 0x28
0000000100000f8d add rax, 0x10
0000000100000f91 mov rcx, qword [rbp+var_10]
0000000100000f95 mov qword [rcx], rax
0000000100000f98 mov qword [rcx+8], rdi
0000000100000f9c add rsp, 0x10
…
0000000100001018 dq 0x00000001000010a8
…
__ZTV1D: // vtable for D
00000001000010a8 db 0x00 ; '.' ; DATA XREF=0x100001018
...
00000001000010b0 dq __ZTI1D
00000001000010b8 db 0xc0 ; '.'
...
00000001000010c8 dq __ZTI1D
00000001000010d0 db 0xc0 ; '.'
…
Ничего не понятно? Смотрите: мы сохраняем два адреса в 0f95 и 0f98. Рассчитываются они исходя из того адреса, что лежит в 1018, плюс 0x28 в первом случае и 0x10 во втором. Итого мы получаем 10b0 и 10d0.
Теперь рассмотрим случай, когда наследование виртуальное.
В плане ассемблерного кода мало что меняется, у нас также хранится два адреса, но виртуальные таблицы для B, C и D стали значительно больше. Например, таблица для класса D увеличилась более чем в 7 раз!
Сэкономили на размере объекта, но увеличили размеры таблиц. А что если мы будем использовать виртуальное наследование повсюду, как советуют некоторые авторы?
Не приведём уже точных ссылок, но где-то читали, что если допускается мысль о множественном наследовании, то всегда нужно использовать виртуальное наследование, дабы уберечься от дублирования.
Итак, начинаем следовать совету в лоб:
class A;
class B: public virtual A;
class C: public virtual A;
class D: public virtual B, public virtual C;
Насколько изменится размер D?
Visual Studio 2015 выведет 4 8 8 16, т. е. добавился еще один указатель в классе D. Путём экспериментов мы выяснили, что, если наследоваться виртуально от каждого класса, то студия добавит еще один указатель в текущий класс. Например, если бы мы написали так:
class D: public virtual B, public C;
или так:
class D: public B, public virtual C;
то размер остался бы 12 байт.
Не подумайте, что студия экономит память, это вовсе не так. Для стандартных настроек размер указателя 4 байта, а не 8, как в gcc. Так что умножайте результат на 2.
А что gcc 4.2.1? Он вообще не изменит размер объектов, вывод все тот же — 8 8 8 16. Но представляете, что стало с таблицей для D?!
На самом деле, она, конечно, увеличилась, но незначительно. Другой вопрос, как это всё повлияет на последующие иерархии.
В качестве чистого эксперимента (не будем думать, есть ли в этом практическая польза) проверим, что случится с такой иерархией:
class A
{
virtual void func() {}
};
class B: public virtual A
{
};
class C: public virtual A
{
};
class D: public virtual B, public virtual C
{
};
class E: public virtual B, public virtual C, public virtual D
{
};
В студии размер класса E возрастет на 4, это мы уже выяснили, а в gcc размер D и E составит 16 байт.
Но при этом размер виртуальной таблицы для класса E (а она и так немаленькая, если убрать все виртуальное наследование) возрастёт в 4 раза! Если я правильно всё посчитал, то он уже достигнет половины килобайта или около того.
Какой же вывод можно сделать? Такой же, как и раньше: множественное наследование стоит использовать очень аккуратно, виртуальное наследование не панацея и, так или иначе, мы за него расплачиваемся. Возможно, стоит подумать в сторону интерфейсов и отказаться от виртуального наследования вообще.
Поделиться с друзьями
Комментарии (9)
tzlom
27.04.2017 10:38«Если я правильно всё посчитал, то он уже достигнет половины килобайта или около того»
Всю статью в виртуальную таблицу не заглядывали, а сейчас как-то её размер посчитали?
Хотя-бы перегрузите аллокатор и покажите сколько байт требуется на экземпляры классов, а в идеале разобрать как оно там лежит и как работает, а то сейчас это голословные утверждения.
Siemargl
27.04.2017 12:18+1Все легко и просто можно увидеть своими глазами на годболте
Нет там половины килобайта. И таблица одна на каждый тип класса.
Да и по скорости проблем тоже нет — всего лишь добавляется один косвенный вызов.
А вот конструкторы становятся нетривиальными.babylon
05.05.2017 18:22-1Подписался на тебя. Почему то чем толковее топикастер, тем ниже у него карма. Это от от бестолковости читающих?
bfDeveloper
Может быть буду непопулярен с таким мнением, но: и что? Если мы не на эмбедед железе, то какое нам дело до размеров виртуальной таблицы? На скорость это, конечно, влияет, но как сильно? Интересно было бы посмотреть на бенчмарки, желательно из реального кода, где кроме вызовов есть что-то ещё. Порог скорости при переходе с невиртуального вызвова, который потенциально инлайнится, на виртуальный мне понятен. А как влияет размер vtbl угадывать не возьмусь.
iCpu
С одной стороны, вы правы. С другой стороны, в статье и не написано, что нужно отказываться от того или иного инструмента. Там скорее о том, что любое лекарство, будучи неправильно применяемо, обращается в яд. И множественное наследование из чисто утилитарных требований «быть кем-то» и «уметь то-то» обращается в кадавра мегабайтных размеров, с рекурсивным спуском по дереву при каждом чихе.