Pull to refresh
105.32
Productivity Inside
Для старательного нет ничего невозможного

Виртуальность и оверхед

Reading time 6 min
Views 6.3K
Я думаю, все знают, что такое наследование или хотя бы слышали о нём. Часто мы используем наследование ради полиморфного поведения объектов. Но задумываемся ли мы о той цене, которую приходится платить за виртуальность? Поставлю вопрос по-другому: каждый ли знает эту цену? Давайте попробуем разобраться в этой проблеме.


В общем случае, выглядит наследование так:

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 раза! Если я правильно всё посчитал, то он уже достигнет половины килобайта или около того.

Какой же вывод можно сделать? Такой же, как и раньше: множественное наследование стоит использовать очень аккуратно, виртуальное наследование не панацея и, так или иначе, мы за него расплачиваемся. Возможно, стоит подумать в сторону интерфейсов и отказаться от виртуального наследования вообще.
Tags:
Hubs:
+17
Comments 9
Comments Comments 9

Articles

Information

Website
productivityinside.com
Registered
Founded
Employees
101–200 employees
Location
Россия