В С++ при определении собственных классов и структур никто нам не запрещает не указывать ни одного поля, оставляя структуру пустой:
struct MyTag {};
Конечно же, мы можем не только объявлять пустые структуры, но и создавать объекты этих типов
struct Tag {};
Tag func(Tag t1) {
Tag t2;
return Tag{};
}
Возможности несомненно полезные и широко используемые:
- Для определения абстрактного статического или динамического полиморфного интерфейса
- Для введения тегов выбора нужной перегрузки
- Для определения различных предикатов и метафункций над типами
А давайте сыграем в игру? Я буду показывать вам разные определения структур, а вы постараетесь угадать их размеры в байтах (sizeof
). Начинаем?
struct StdAllocator {};
struct Vector1 {
int* data;
int* size_end;
int* capacity_end;
StdAllocator alloc;
};
struct Vector2 {
StdAllocator alloc;
int* data;
int* size_end;
int* capacity_end;
};
struct Vector3 : StdAllocator {
int* data;
int* size_end;
int* capacity_end;
};
Vector1
и Vector2
имеют размеры 4*sizeof(int*)
.
Но как же так?! Откуда берутся 3*sizeof(int*)
совершенно очевидно. Но четвертый-то откуда?!
Все очень просто: в C++ не бывает структур нулевого размера. И потому размер пустой структуры: sizeof(StdAllocator) == 1
Но sizeof(int*) != 1
. По крайней мере на x86. А это еще проще: выравнивание и паддинг. Vector1
добивается байтами в конец, чтобы его размер был кратен выравниванию первого поля. А в Vector2
добиваются дополнительные байты между alloc
и data
, чтобы смещение до data
было кратным его выравниванию. Все очень просто и очевидно! Если же вам, как и многим другим людям, которые не задаются подобными вопросами каждый день, не очевидно наличие паддинга в той или иной структуре, то советую использовать флаг компилятора -Wpadded
для GCC/Clang.
Хорошо, мы разобрались с Vector1
и Vector2
. А что там с Vector3
? Тоже 4*sizeof(int*)
? Ведь мы же знаем, что подобъект базового класса должен быть где-то размещен, а его размер, как мы выяснили, не нулевой...
А вот и нет! Размер Vector3
равен 3*sizeof(int*)
! Но как же так?! А это называется EBO (empty base optimization).
Интересный zero-cost! Для сравнения, можно глянуть на аналогичные пустые структуры в Rust. Там их размер может быть равен нулю.
Ну ладно, мы выяснили, что, неаккуратно использовав пустые структуры, мы можем получить увеличение потребления памяти. Давайте играть дальше.
struct StdAllocator {};
struct StdComparator {};
struct Map1 {
StdAllocator alloc;
StdComparator comp;
};
struct Map2 {
StdAllocator alloc;
[[no_unique_address]] StdComparator comp;
};
struct Map3 {
[[no_unique_address]] StdAllocator alloc;
[[no_unique_address]] StdComparator comp;
};
struct MapImpl1 : Map1 {
int x;
};
struct MapImpl2 : Map2 {
int x;
};
struct MapImpl3 : Map3 {
int x;
};
Чему равны размеры Map1
, Map2
, Map3
?
Ну, тут все просто!:
- Очевидно, что
sizeof(Map1) == 2
, ведь она состоит из двух пустых структур, каждая из которых имеет размер 1. - Благодаря атрибуту
[[no_unique_address]]
из стандарта C++20 (Clang поддерживает с C++11),Map2
иMap3
должны иметь размер 1. ВMap3
оба поля разделяют общий адрес. ВMap2
то же самое. Да и меньше чем 1 не бывает.
Хорошо. А что же теперь с наследующими структурами?
Все по 2*sizeof(int)
? А вот и нет: у MapImpl3
работает EBO!
Ну ладно. В этом есть какая-то логика и закономерность. Это еще можно принять. Хотя... На самом деле вы были правы! Ведь если у вас компилятор msvc, то [[no_unique_address]]
просто не работает. И не будет работать. Потому что msvc долгое время просто игнорировал незнакомые ему атрибуты. И если поддержать [[no_unique_address]]
, то сломается бинарная совместимость. Используйте [[msvc::no_unique_address]]
! EBO, правда, пока не работает.
Язык C (не С++), начиная с версии стандарта 99, позволяет использовать следующую любопытную конструкцию:
struct ImageHeader{
int h;
int w;
};
struct Image {
struct ImageHeader header;
char data[];
};
Поле data
в структуре Image
имеет нулевой размер. Это FAM (flexible array member). Очень удобная штука, чтобы получать доступ к массиву статически не известной длины, размещенному сразу после некоторого заголовка в бинарном буфере. Длина массива обычно указывается в самом заголовке. FAM может быть только последним полем в структуре.
Стандарт C++ такие фичи не разрешает. Но ведь есть GCC с его нестандартными включенными по умолчанию расширениями.
Что будет если сделать так?
struct S {
char data[];
};
Чему будет равен размер структуры S
?
В стандартном C пустые структуры в принципе запрещены. И поведение программы с ними не определено. GCC определяет их размер нулевым при компиляции C программ. А при компиляции C++ — размер, как мы выяснили ранее, единичный. Дело пахнет страшными багами и ночными кошмарами при неосторожном проектировании C++ библиотек с сишным интерфейсом или использованием C-библиотек в C++!
Но вернемся все-таки к нашей структуре с FAM. Поле в ней есть. Стандартный C опять-таки требует, чтобы было еще хотя бы одно поле ненулевой длины перед FAM. GNU C же охотно сделает нам структуру нулевого размера.
А теперь посмотрим на GCC C++.
struct S1 {
char data[];
};
struct S2 {};
static_assert(sizeof(S1) != sizeof(S2));
static_assert(sizeof(S1) == 0);
И вот уже внезапно у нас в C++ структуры нулевого размера. Только C++ не стандартный. Каким образом такие структуры будет взаимодействовать с EBO — нужно читать в спецификации к GCC.
Кстати об empty base optimization, на cppreference можно обнаружить сноску, что oбычно при наследовании от пустой структуры размер структуры-наследника не увеличивается. Обычно.
Но вот и пример, когда происходит что-то необычное
#include <type_traits>
struct EBO {};
struct A : EBO {};
struct B : EBO {};
struct C : EBO {};
static_assert(std::is_empty_v<A>);
static_assert(std::is_empty_v<B>);
static_assert(std::is_empty_v<C>);
struct D : A, B, C {};
static_assert(std::is_empty_v<D>);
static_assert(sizeof(D) == 3);
Под MSVC размер структуры D
равен 1. А под Clang и GCC -- 3.
MSVC при этом, согласно стандарту, не прав, хотя его вариант почти всегда был бы предпочтителен
Empty base optimization is prohibited if one of the empty base classes is also the type or the base of the type of the first non-static data member, since the two base subobjects of the same type are required to have different addresses within the object representation of the most derived type.
Мы видели, что неаккуратное использование пустых структур приводит к увеличению размера других, не пустых структур. А может еще есть какие-то подводные камни? Например, при использовании пустых структур-тегов для выбора перегрузки?
Есть ли разница между
struct Mul {};
struct Add {};
int op(Mul, int x, int y) {
return x * y;
}
int op(Add, int x, int y) {
return x + y;
}
и
int mul(int x, int y) {
return x * y;
}
int add(int x, int y) {
return x + y;
}
в плане генерируемого кода?
Краткий ответ: да. Есть разница. Зависит от конкретной имплементации. Стандарт не гарантирует оптимизацию пустых аргументов. От перемены позиций тегов может меняться бинарный интерфейс. Поиграться с наиболее заметными изменениями можно на примере msvc.