Стандартная компоновка и обивка хвоста

Дэвид Холлман недавно написал в Твиттере следующий пример (который я немного сократил):

struct FooBeforeBase {
    double d;
    bool b[4];
};

struct FooBefore : FooBeforeBase {
    float value;
};

static_assert(sizeof(FooBefore) > 16);

//----------------------------------------------------

struct FooAfterBase {
protected:
    double d;
public:  
    bool b[4];
};

struct FooAfter : FooAfterBase {
    float value;
};

static_assert(sizeof(FooAfter) == 16);

Вы можете проверить макет в clang на godbolt и увидеть, что причина изменения размера заключается в FooBefore, что в FooBefore value элемента размещается по смещению 16 (с сохранением полного выравнивания 8 из FooBeforeBase), тогда как в FooAfter value элемента размещается в смещение 12 (эффективно с использованием FooAfterBase tail-padding).

Для меня очевидно, что FooBeforeBase - это стандартная компоновка, а FooAfterBase - нет (поскольку не все элементы нестатических данных имеют одинаковый контроль доступа, [class.prop]/3). Но каково то, что FooBeforeBase является стандартным макетом, который требует этого отношения байтов заполнения?

И gcc, и clang повторно FooAfterBase заполнение FooAfterBase, заканчиваясь на sizeof(FooAfter) == 16. Но MSVC этого не делает, заканчивая 24. Есть ли требуемая раскладка в соответствии со стандартом и, если нет, почему gcc и clang делают то, что они делают?


Существует некоторая путаница, поэтому просто чтобы прояснить:

  • FooBeforeBase является стандартным макетом
  • FooBefore нет (и it, и базовый класс имеют нестатические члены-данные, аналогично E в этом примере)
  • FooAfterBase - нет (он имеет нестатические элементы данных разного доступа)
  • FooAfter нет (по обеим выше причинам)

Ответы

Ответ 1

Вот конкретный случай, который демонстрирует, почему второй случай не может повторно использовать заполнение:

union bob {
  FooBeforeBase a;
  FooBefore b;
};

bob.b.value = 3.14;
memset( &bob.a, 0, sizeof(bob.a) );

это не может очистить bob.b.value.

union bob2 {
  FooAfterBase a;
  FooAfter b;
};

bob2.b.value = 3.14;
memset( &bob2.a, 0, sizeof(bob2.a) );

это неопределенное поведение.

Ответ 2

FooBefore derived;
FooBeforeBase src, &dst=derived;
....
memcpy(&dst, &src, sizeof(dst));

Если бы дополнительный элемент данных был помещен в отверстие, memcpy перезаписал бы его.

Как правильно указано в комментариях, стандарт не требует, чтобы этот вызов memcpy работал. Однако Itanium ABI, похоже, разработан с учетом этого случая. Возможно, правила ABI определены таким образом, чтобы сделать программирование на нескольких языках более надежным или сохранить некоторую обратную совместимость.

Соответствующие правила ABI можно найти здесь.

Соответствующий ответ можно найти здесь (этот вопрос может быть дубликатом этого).

Ответ 3

FooBefore не является std-layout; два класса объявляют нестатические члены данных (FooBefore и FooBeforeBase). Таким образом, компилятору разрешено произвольно размещать некоторые элементы данных. Отсюда возникают различия в разных цепях инструментов. В иерархии std-layout не более одного класса (либо самого производного класса, либо не более одного промежуточного класса) должны объявлять нестатические члены-данные.

Ответ 4

Ответ на этот вопрос исходит не от стандарта, а от Itanium ABI (именно поэтому gcc и clang имеют одно поведение, а msvc - другое). Этот ABI определяет макет, соответствующими частями которого для целей этого вопроса являются:

Для внутренних целей спецификации мы также указываем:

  • dsize (O): размер данных объекта, который равен размеру O без добавления хвоста.

а также

Мы игнорируем добавление хвоста для POD, потому что ранняя версия стандарта не позволяла нам использовать его для чего-либо еще, а также потому, что иногда это позволяет быстрее копировать тип.

Где размещение членов, отличных от виртуальных базовых классов, определяется как:

Начните со смещения dsize (C), увеличивая при необходимости для выравнивания по nvalign (D) для базовых классов или для выравнивания (D) для членов данных. Поместите D в это смещение, если [... не релевантно...].

Термин POD исчез из стандарта C++, но он означает макет стандарта и тривиально копируемый. В этом вопросе FooBeforeBase является POD. Itanium ABI игнорирует заполнение хвоста - следовательно, dsize(FooBeforeBase) равен 16.

Но FooAfterBase - это не POD (его легко копировать, но это не стандартная схема). В результате, заполнение хвоста не игнорируется, поэтому dsize(FooAfterBase) составляет всего 12, и float может идти прямо туда.

Это имеет интересные последствия, как указывает Quuxplusone в связанном ответе, разработчики также обычно предполагают, что хвостовая подкладка не используется повторно, что приводит к хаосу в этом примере:

#include <algorithm>
#include <stdio.h>

struct A {
    int m_a;
};

struct B : A {
    int m_b1;
    char m_b2;
};

struct C : B {
    short m_c;
};

int main() {
    C c1 { 1, 2, 3, 4 };
    B& b1 = c1;
    B b2 { 5, 6, 7 };

    printf("before operator=: %d\n", int(c1.m_c));  // 4
    b1 = b2;
    printf("after operator=: %d\n", int(c1.m_c));  // 4

    printf("before std::copy: %d\n", int(c1.m_c));  // 4
    std::copy(&b2, &b2 + 1, &b1);
    printf("after std::copy: %d\n", int(c1.m_c));  // 64, or 0, or anything but 4
}

Здесь = правильно делает (не переопределяет хвостовое заполнение B), но copy() имеет библиотечную оптимизацию, которая сводится к memmove() которая не заботится о заполнении хвоста, потому что предполагает, что она не существует.

Ответ 5

Здесь аналогичный случай, как ответ NM.

Во-первых, пусть есть функция, которая очищает FooBeforeBase:

void clearBase(FooBeforeBase *f) {
    memset(f, 0, sizeof(*f));
}

Это нормально, так как clearBase получает указатель на FooBeforeBase, он считает, что FooBeforeBase имеет стандартную компоновку, поэтому memsetting безопасен.

Теперь, если вы сделаете это:

FooBefore b;
b.value = 42;
clearBase(&b);

Вы не ожидаете, что clearBase очистит b.value, поскольку b.value не является частью FooBeforeBase. Но, если FooBefore::value будет помещен в хвостовую часть FooBeforeBase, он также будет очищен.

Существует ли требуемый макет в соответствии со стандартом и, если нет, почему gcc и clang делают то, что они делают?

Нет, набивка хвоста не требуется. Это оптимизация, которую делают gcc и clang.