Files
ubbook/syntax/zero_size.md
T
2022-03-24 14:03:41 +03:00

10 KiB
Raw Blame History

Типы «нулевого» размера

В С++ при определении собственных классов и структур никто нам не запрещает не указывать ни одного поля, оставляя структуру пустой:

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.

Полезные ссылки

  1. https://devblogs.microsoft.com/cppblog/msvc-cpp20-and-the-std-cpp20-switch
  2. https://en.cppreference.com/w/cpp/language/attributes/no_unique_address
  3. https://en.cppreference.com/w/cpp/language/ebo