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