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


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

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)


  1. bfDeveloper
    26.04.2017 14:30
    +2

    Может быть буду непопулярен с таким мнением, но: и что? Если мы не на эмбедед железе, то какое нам дело до размеров виртуальной таблицы? На скорость это, конечно, влияет, но как сильно? Интересно было бы посмотреть на бенчмарки, желательно из реального кода, где кроме вызовов есть что-то ещё. Порог скорости при переходе с невиртуального вызвова, который потенциально инлайнится, на виртуальный мне понятен. А как влияет размер vtbl угадывать не возьмусь.


    1. iCpu
      27.04.2017 08:10
      +1

      С одной стороны, вы правы. С другой стороны, в статье и не написано, что нужно отказываться от того или иного инструмента. Там скорее о том, что любое лекарство, будучи неправильно применяемо, обращается в яд. И множественное наследование из чисто утилитарных требований «быть кем-то» и «уметь то-то» обращается в кадавра мегабайтных размеров, с рекурсивным спуском по дереву при каждом чихе.


  1. Error1024
    26.04.2017 19:23

    Уже написал в личку, автор не отреагировал, почему статья в хабе «Objective C»?
    Намного логичнее разместить ее в «C++»


    1. nanton
      27.04.2017 08:01

      Спасибо за наблюдение, исправили недочет


  1. tzlom
    27.04.2017 10:38

    «Если я правильно всё посчитал, то он уже достигнет половины килобайта или около того»
    Всю статью в виртуальную таблицу не заглядывали, а сейчас как-то её размер посчитали?
    Хотя-бы перегрузите аллокатор и покажите сколько байт требуется на экземпляры классов, а в идеале разобрать как оно там лежит и как работает, а то сейчас это голословные утверждения.


  1. Siemargl
    27.04.2017 12:18
    +1

    Все легко и просто можно увидеть своими глазами на годболте

    Нет там половины килобайта. И таблица одна на каждый тип класса.
    Да и по скорости проблем тоже нет — всего лишь добавляется один косвенный вызов.

    А вот конструкторы становятся нетривиальными.


    1. babylon
      05.05.2017 18:22
      -1

      Подписался на тебя. Почему то чем толковее топикастер, тем ниже у него карма. Это от от бестолковости читающих?


  1. Ryppka
    27.04.2017 17:06

    В 32-х разрядной архитектуре размер будет равен 8. 4 — указатель + 4 int без выравнивания.

    А как правильно писать по-русски — без выравнивания или без паддинга/слопа? Есть устоявшийся русский термин?


    1. Error1024
      28.04.2017 01:31
      +1

      Термин «выравнивание» вполне устоявшийся