10 KiB
Типы «нулевого» размера
В С++ при определении собственных классов и структур никто нам не запрещает не указывать ни одного поля, оставляя структуру пустой:
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, правда, пока не работает.
zero-size array
Язык 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.
tag dispatching
Мы видели, что неаккуратное использование пустых структур приводит к увеличению размера других, не пустых структур. А может еще есть какие-то подводные камни? Например, при использовании пустых структур-тегов для выбора перегрузки?
Есть ли разница между
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.