Я думаю, все знают, что такое наследование или хотя бы слышали о нём. Часто мы используем наследование ради полиморфного поведения объектов. Но задумываемся ли мы о той цене, которую приходится платить за виртуальность? Поставлю вопрос по-другому: каждый ли знает эту цену? Давайте попробуем разобраться в этой проблеме.
В общем случае, выглядит наследование так:
При этом, как мы прекрасно знаем, класс С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 раза! Если я правильно всё посчитал, то он уже достигнет половины килобайта или около того.
Какой же вывод можно сделать? Такой же, как и раньше: множественное наследование стоит использовать очень аккуратно, виртуальное наследование не панацея и, так или иначе, мы за него расплачиваемся. Возможно, стоит подумать в сторону интерфейсов и отказаться от виртуального наследования вообще.