Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top

Вебинар: Статический анализ кода в методическом документе ЦБ РФ "Профиль защиты" - 16.02

>
>
>
Тихий враг или молчаливый союзник:...

Тихий враг или молчаливый союзник: коротко о выравнивании в C++. Часть 2

02 Фев 2026

Казалось бы, тайна выравнивания раскрыта. Вы победили невидимого врага — невыровненный доступ. Память под контролем, но производительность по-прежнему шепчет: "Есть ещё нюансы". Что? Нюансы? Какие? Пришло время посмотреть, что происходит, когда структуры начинают наследовать друг друга. Здесь всё становится... интереснее. Правила игры меняются.

Введение

Итак, путь ясен: мы погружаемся в мир наследования, чтобы услышать его диалог с памятью. Давайте сразу к делу. Приготовьтесь, правила только что усложнились.

Влияние наследования на выравнивание

Выравнивание данных в памяти для таких случаев становится более интересным, чем в обычных структурах. Тут уже компилятор должен учитывать не только поля текущего класса, но и поля всех его родителей, сохраняя при этом порядок их объявления.

Наследование POD-структур

Классы/структуры в C++ имеют два интересных свойства — они тривиальны и имеют стандартное размещение в памяти. Раньше классы/структуры, в которых соблюдаются оба свойства, называли POD (Plain Old Data). Важная фишка POD-классов/структур — они будут отражены в памяти именно так, как вы их определили.

Начнём с наследования POD-структур как наиболее простого варианта:

struct Base
{
  char* ptr;
  int a;
  char b;
};

struct Example : Base
{
  char array[3];
};
#include <iostream>
#include <cstdint>
#include <format>

struct Base
{
  char* ptr;
  int a;
  char b;
};

struct Example : Base
{
  char array[3];
};

int main()
{
  Example obj;
  std::cout << "=== Print size and alignment of "
               "struct derived from POD ==="
            << std::endl
            << std::format("Sizeof of Base: {} byte(s)",
                           sizeof(Base))
            << std::endl
            << std::format("Alignment of Base: {} byte(s)",
                           alignof(Base))
            << std::endl
            << std::format("Sizeof of Example: {} bytes",
                           sizeof(struct Example))
            << std::endl
            << std::format("Alignment of Example: {} byte(s)",
                           alignof(Example))
            << std::endl << std::endl
            << "=== Addresses ===" << std::endl
            << std::format("Base address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                             static_cast<Base *>(&obj)
                           ))
            << std::endl
            << std::format("Base first "
                           "data member address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&obj.ptr))
            << std::endl
            << std::format("Example first "
                           "data member address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&obj.array))
            << std::endl;
}

Compiler Explorer: https://godbolt.org/z/zMEfvGqnT

=== Print size and alignment of struct derived from POD ===
Sizeof of Base: 16 byte(s)
Alignment of Base: 8 byte(s)
Sizeof of Example: 24 bytes
Alignment of Example: 8 byte(s)

=== Addresses ===
Base address: 0x9d5ecffc70
Base first data member address: 0x9d5ecffc70
Example first data member address: 0x9d5ecffc80

Compiler Explorer: https://godbolt.org/z/PnWsTY8sc

=== Print size and alignment of struct derived from POD ===
Sizeof of Base: 16 byte(s)
Alignment of Base: 8 byte(s)
Sizeof of Example: 24 bytes
Alignment of Example: 8 byte(s)

=== Addresses ===
Base address: 0x7ffdbb57caa0
Base first data member address: 0x7ffdbb57caa0
Example first data member address: 0x7ffdbb57cab0

При наследовании POD-структуры происходит неявная композиция: в начало производной структуры вставляется целиком базовая, как будто она первое поле. Здесь компиляторы не смогли что-то оптимизировать, т. к. POD-структура обладает свойством стандартного размещения в памяти.

Наследование не-POD-структуры/класса

Когда класс/структура наследуется от не-POD-класса/структуры, правила размещения данных несколько меняются.

Рассмотрим пример:

struct Base
{
  Base();

  char* ptr;
  int a;
  char b;
};

struct Example : Base
{
  char array[3];
};
#include <iostream>
#include <cstdint>
#include <format>

struct Base
{
  Base();

  char* ptr;
  int a;
  char b;
};

struct Example : Base
{
  char array[3];
};

int main()
{
  Example obj;
  std::cout << "=== Print size and alignment of "
               "struct derived from non-POD ==="
            << std::endl
            << std::format("Sizeof of Base: {} byte(s)",
                           sizeof(Base))
            << std::endl
            << std::format("Alignment of Base: {} byte(s)",
                           alignof(Base))
            << std::endl
            << std::format("Sizeof of Example: {} bytes",
                           sizeof(struct Example))
            << std::endl
            << std::format("Alignment of Example: {} byte(s)",
                           alignof(Example))
            << std::endl << std::endl
            << "=== Addresses ===" << std::endl
            << std::format("Base address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                             static_cast<Base *>(&obj)
                           ))
            << std::endl
            << std::format("Base first "
                           "data member address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&obj.ptr))
            << std::endl
            << std::format("Example first "
                           "data member address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&obj.array))
            << std::endl;
}

Compiler Explorer: https://godbolt.org/z/3o8dxK3Pf

=== Print size and alignment of struct derived from non-POD ===
Sizeof of Base: 16 byte(s)
Alignment of Base: 8 byte(s)
Sizeof of Example: 24 bytes
Alignment of Example: 8 byte(s)

=== Addresses ===
Base address: 0x84c88ff970
Base first data member address: 0x84c88ff970
Example first data member address: 0x84c88ff980

Compiler Explorer: https://godbolt.org/z/EePET54bY

=== Print size and alignment of struct derived from non-POD ===
Sizeof of Base: 16 byte(s)
Alignment of Base: 8 byte(s)
Sizeof of Example: 16 bytes
Alignment of Example: 8 byte(s)

=== Addresses ===
Base address: 0x7ffc8f937638
Base first data member address: 0x7ffc8f937638
Example first data member address: 0x7ffc8f937645

В случае компилятора MSVC ничего не поменялось: мы всё так же имеем неявную композицию. А вот с Clang случилась какая-то магия: размер производного класса меньше на 8 байт. Самое время вспомнить про термин dsize, который мы вводили при рассмотрении алгоритма компоновки полей. Значение dsize — это размер класса/структуры без учёта финального выравнивания. И в случае наследования от не-POD-структуры/класса, Clang устраняет финальное выравнивание, чтобы компактнее упаковать следующие базовые классы или поля.

Все твои базы принадлежат нам (оптимизация пустой базы)

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

struct EmptyBase
{
  void show() { std::cout << "Empty" << std::endl; }
};

struct Example : EmptyBase
{
  char* ptr;
  long value;
  short number;
  char symbol;
};
#include <iostream>
#include <cstdint>
#include <format>

struct EmptyBase
{
  void show() { std::cout << "Empty" << std::endl; }
};

struct Example : EmptyBase
{
  char* ptr;
  int value;
  short number;
  char pair[2];
};

int main()
{
  Example obj;
  std::cout << "=== Print Empty Base Optimization ==="
            << std::endl
            << std::format("EmptyBase size: {} byte(s)",
                           sizeof(EmptyBase))
            << std::endl
            << std::format("EmptyBase alignment: {} byte(s)",
                           alignof(EmptyBase))
            << std::endl
            << std::format("Example size: {} byte(s)",
                           sizeof(Example))
            << std::endl
            << std::format("Example alignment: {} byte(s)",
                           alignof(Example))
            << std::endl << std::endl
            << "=== Addresses ===" << std::endl
            << std::format("Object address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&obj))
            << std::endl
            << std::format("EmptyBase address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                             static_cast<EmptyBase *>(&obj)
                           ))
            << std::endl
            << std::format("Example first "
                           "data member address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&obj.ptr))
            << std::endl;
}

Compiler Explorer: https://godbolt.org/z/o6TGcoKcq

=== Print Empty Base Optimization ===
EmptyBase size: 1 byte(s)
EmptyBase alignment: 1 byte(s)
Example size: 16 byte(s)
Example alignment: 8 byte(s)

=== Addresses ===
Object address: 0x77b3aff7d0
EmptyBase address: 0x77b3aff7d0
Example first data member address: 0x77b3aff7d0

Compiler Explorer: https://godbolt.org/z/GWch15ha4

=== Print Empty Base Optimization ===
EmptyBase size: 1 byte(s)
EmptyBase alignment: 1 byte(s)
Example size: 16 byte(s)
Example alignment: 8 byte(s)

=== Addresses ===
Object address: 0x7ffcc30b6c98
EmptyBase address: 0x7ffcc30b6c98
Example first data member address: 0x7ffcc30b6c98

Адрес объекта, адрес базовой части и адрес первого поля производной структуры указывают на один и тот же адрес. Базовый класс EmptyBase буквально "растворился" в своём потомке, не добавляя ни байта к его размеру. Вся магия заключается в том, что компилятор применил оптимизацию пустого базового класса. Он пользуется тем фактом, что пустой базовый класс не содержит данных, следовательно, не требует выделения отдельной памяти под них. Поскольку у родителя нет полей, его существование в памяти — чистая формальность, необходимая для соблюдения правил языка.

Таким образом, когда производный класс наследуется от пустого базового класса, компилятор может разместить базовый класс "внутри" производного, совместив его адрес с адресом первого поля производного класса. Это позволяет экономить память, не нарушая правил выравнивания.

Множественное наследование

Здесь не будет никаких секретов. Вооружаемся ранее полученной информацией про наследование POD и не-POD структур/классов и проводим всё согласно списку наследования.

struct Base1
{
  Base1();

  long long a;
  int b;
};

struct Base2
{
  short c;
  char d;
};

struct Example : Base1, Base2
{
  char e;
};
#include <iostream>
#include <cstdint>
#include <format>

struct Base1
{
  Base1();

  long long a;
  int b;
};

struct Base2
{
  char c;
  char d;
};

struct Example : Base1, Base2
{
  char e;
};

int main()
{
  Example obj;
  std::cout << "=== Multiple Inheritance ==="
            << std::endl
            << std::format("Base1 size: {} byte(s)",
                           sizeof(Base1))
            << std::endl
            << std::format("Base1 alignment: {} byte(s)",
                           alignof(Base1))
            << std::endl
            << std::format("Base2 size: {} byte(s)",
                           sizeof(Base2))
            << std::endl
            << std::format("Base2 alignment: {} byte(s)",
                           alignof(Base2))
            << std::endl
            << std::format("Example size: {} byte(s)",
                           sizeof(Example))
            << std::endl
            << std::format("Example alignment: {} byte(s)",
                           alignof(Example))
            << std::endl << std::endl
            << "=== Addresses ===" << std::endl
            << std::format("Base1 address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                            static_cast<Base1 *>(&obj)
                           ))
            << std::endl
            << std::format("Base2 address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                            static_cast<Base2 *>(&obj)
                           ))
            << std::endl
            << std::format("Base1 first member address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&obj.a))
            << std::endl
            << std::format("Base2 first member address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&obj.c))
            << std::endl
            << std::format("Example first member address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&obj.e))
            << std::endl;
}

Compiler Explorer: https://godbolt.org/z/13f4fbdna

=== Multiple Inheritance ===
Base1 size: 16 byte(s)
Base1 alignment: 8 byte(s)
Base2 size: 2 byte(s)
Base2 alignment: 1 byte(s)
Example size: 24 byte(s)
Example alignment: 8 byte(s)

=== Addresses ===
Base1 address: 0xbaf12ffaa8
Base2 address: 0xbaf12ffab8
Base1 first member address: 0xbaf12ffaa8
Base2 first member address: 0xbaf12ffab8
Example first member address: 0xbaf12ffaba

Compiler Explorer: https://godbolt.org/z/G5j1adsM3

=== Multiple Inheritance ===
Base1 size: 16 byte(s)
Base1 alignment: 8 byte(s)
Base2 size: 2 byte(s)
Base2 alignment: 1 byte(s)
Example size: 16 byte(s)
Example alignment: 8 byte(s)

=== Addresses ===
Base1 address: 0x7ffc4073bbe8
Base2 address: 0x7ffc4073bbf4
Base1 first member address: 0x7ffc4073bbe8
Base2 first member address: 0x7ffc4073bbf4
Example first member address: 0x7ffc4073bbf6

MSVC располагает две базовые структуры как есть путём композиции. Компоновка структуры Example получится следующей: 16 байт Base1 + 2 байта Base2 + 1 байт поля e + 5 байт финального выравнивания = 24 байта.

В случае Clang дела обстоят интереснее. При компоновке структуры Example первой на рассмотрение подаётся базовая структура Base1. Про неё можно сказать следующее:

  • она не является POD;
  • размер без финального выравнивания dsize == 12;
  • итоговый размер — 16 байт;
  • выравнивание — 8 байт.

Далее идёт базовая структура Base2. Про неё мы можем сказать, что:

  • она является POD;
  • итоговый размер — 2 байта;
  • выравнивание — 1 байт.

Выравнивание предыдущей базовой структуры больше текущей, значит паддинги вставлять не надо. Компоновка структуры Example получится следующей: 12 байт Base1 + 2 байта Base2 + 1 байт поля e + 1 байт финального выравнивания = 16 байт.

Как можно заметить и в этом примере, Clang более компактно расположил данные благодаря Itanium ABI по сравнению с MSVC.

Множественное наследование пустых классов

Ещё интереснее дела обстоят со множественным наследованием пустых классов. Рассмотрим следующий пример:

struct Empty1 {};
struct Empty2 {};
struct NonEmpty { short a; };
struct Empty3 {};
struct Empty4 {};

struct Example : Empty1, Empty2, NonEmpty, Empty3, Empty4
{
  char* ptr;
  long value; 
  short number;
  char symbol;
};
#include <iostream>
#include <format>

struct Empty1 {};
struct Empty2 {};
struct NonEmpty { short a; };
struct Empty3 {};
struct Empty4 {};

struct Example : Empty1, Empty2, NonEmpty, Empty3, Empty4
{
  char* ptr;
  long value; 
  short number;
  char symbol;
};

int main()
{
  Example x;
  std::cout << "=== Multiple Empty Base Inheritance ==="
            << std::endl
            << std::format("Empty1 size: {} byte(s)",
                           sizeof(Empty1))
            << std::endl
            << std::format("Empty1 alignment: {} byte(s)",
                           alignof(Empty1))
            << std::endl
            << std::format("Empty2 size: {} byte(s)",
                           sizeof(Empty2))
            << std::endl
            << std::format("Empty2 alignment: {} byte(s)",
                           alignof(Empty2))
            << std::endl
            << std::format("NonEmpty size: {} byte(s)",
                           sizeof(NonEmpty))
            << std::endl
            << std::format("NonEmpty alignment: {} byte(s)",
                           alignof(NonEmpty))
            << std::endl
            << std::format("Empty3 size: {} byte(s)",
                           sizeof(Empty3))
            << std::endl
            << std::format("Empty3 alignment: {} byte(s)",
                           alignof(Empty3))
            << std::endl
            << std::format("Empty4 size: {} byte(s)",
                           sizeof(Empty4))
            << std::endl
            << std::format("Empty4 alignment: {} byte(s)",
                           alignof(Empty4))
            << std::endl
            << std::format("Example size: {} byte(s)",
                           sizeof(Example))
            << std::endl
            << std::format("Example alignment: {} byte(s)",
                           alignof(Example))
            << std::endl
            << "=== Addresses ===" << std::endl
            << std::format("Object address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&x))
            << std::endl
            << std::format("Empty1 address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                             static_cast<Empty1*>(&x)
                           ))
            << std::endl
            << std::format("Empty2 address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                             static_cast<Empty2*>(&x)
                           ))
            << std::endl
            << std::format("NonEmpty address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                             static_cast<NonEmpty*>(&x)
                           ))
            << std::endl
            << std::format("Empty3 address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                             static_cast<Empty3*>(&x)
                           ))
            << std::endl
            << std::format("Empty4 address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(
                             static_cast<Empty4*>(&x)
                           ))
            << std::endl
            << std::format("First member address: 0x{:x}",
                           reinterpret_cast<uintptr_t>(&x.ptr))
            << std::endl << std::endl;
}

Compiler Explorer: https://godbolt.org/z/xPT7qq33o

=== Multiple Empty Base Inheritance ===
Empty1 size: 1 byte(s)
Empty1 alignment: 1 byte(s)
Empty2 size: 1 byte(s)
Empty2 alignment: 1 byte(s)
NonEmpty size: 2 byte(s)
NonEmpty alignment: 2 byte(s)
Empty3 size: 1 byte(s)
Empty3 alignment: 1 byte(s)
Empty4 size: 1 byte(s)
Empty4 alignment: 1 byte(s)
Example size: 24 byte(s)
Example alignment: 8 byte(s)

=== Addresses ===
Object address: 0xa550cffb70
Empty1 address: 0xa550cffb70
Empty2 address: 0xa550cffb71
NonEmpty address: 0xa550cffb72
NonEmpty first member address: 0xa550cffb72
Empty3 address: 0xa550cffb74
Empty4 address: 0xa550cffb75
Example first member address: 0xa550cffb78

Compiler Explorer: https://godbolt.org/z/nEqd4bqEY

=== Multiple Empty Base Inheritance ===
Empty1 size: 1 byte(s)
Empty1 alignment: 1 byte(s)
Empty2 size: 1 byte(s)
Empty2 alignment: 1 byte(s)
NonEmpty size: 2 byte(s)
NonEmpty alignment: 2 byte(s)
Empty3 size: 1 byte(s)
Empty3 alignment: 1 byte(s)
Empty4 size: 1 byte(s)
Empty4 alignment: 1 byte(s)
Example size: 32 byte(s)
Example alignment: 8 byte(s)

=== Addresses ===
Object address: 0x7ffdf04c06d0
Empty1 address: 0x7ffdf04c06d0
Empty2 address: 0x7ffdf04c06d0
NonEmpty address: 0x7ffdf04c06d0
NonEmpty first member address: 0x7ffdf04c06d0
Empty3 address: 0x7ffdf04c06d0
Empty4 address: 0x7ffdf04c06d0
Example first member address: 0x7ffdf04c06d8

Можно заметить, что MSVC оптимизирует только первый пустой базовый класс. В то же время Clang разместил четыре пустых базовых класса в самом начале объекта, даже несмотря на их порядок в списке наследования. Почему MSVC так это делает? Исторические причины :)

К счастью, можно добиться такого же поведения и для MSVC, начиная с Visual Studio 2015 Update 3. Для этого надо воспользоваться атрибутом __declspec(empty_bases).

Заключение

Итак, тайна углубляется, но уже не пугает. Выравнивание при наследовании — это не хаос, а чёткий механизм, правила которого мы только что разобрали. Теперь вы понимаете, как иерархия классов превращается в память, и можете предсказать, где спрячется следующий "невидимый враг". Однако мир C++ неисчерпаем: виртуальные базовые классы и тонкости оптимизации vtable ждут своего часа. Продолжение следует... :)

Чтобы на практике убедиться, что переходы между классами в иерархии не создают скрытых проблем с памятью, вы можете воспользоваться статическим анализатором PVS-Studio. Он поможет проверить корректность компоновки объектов и станет вашим надёжным союзником в построении эффективных иерархий.

Последние статьи:

Опрос:

book gost

Дарим
электронную книгу
за подписку!

Популярные статьи по теме


Комментарии (0)

Следующие комментарии next comments
close comment form