21

David Hollman 最近在推特上发布了以下示例(我稍微简化了):

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);

您可以在 godbolt 上检查 clang 中的布局,发现大小更改的原因是,在 中FooBefore,成员value被放置在偏移 16 处(保持 8 的完全对齐FooBeforeBase),而在 中FooAfter,成员value被放置在偏移 12 处(有效地使用FooAfterBase的尾部填充)。

我很清楚这FooBeforeBase是标准布局,但FooAfterBase不是(因为它的非静态数据成员并不都具有相同的访问控制,[class.prop]/3)。但是FooBeforeBase' 是需要这种填充字节的标准布局是什么?

gcc 和 clang 都重用FooAfterBase了 的填充,最终以sizeof(FooAfter) == 16. 但 MSVC 没有,以 24 结尾。是否有按照标准要求的布局,如果没有,为什么 gcc 和 clang 会这样做?


有一些混乱,所以只是为了澄清:

  • FooBeforeBase是标准布局
  • FooBefore不是(它和基类都有静态数据成员,类似于E
  • FooAfterBase不是(它具有不同访问权限的非静态数据成员
  • FooAfter不是(出于上述两个原因
4

5 回答 5

13

这个问题的答案不是来自标准,而是来自 Itanium ABI(这就是为什么 gcc 和 clang 有一种行为而 msvc 做其他事情的原因)。该 ABI 定义了 layout,就本问题而言,其相关部分是:

出于规范内部的目的,我们还指定:

  • dsize (O):对象的数据大小,即不加尾填充的O大小。

我们忽略了 POD 的尾部填充,因为该标准的早期版本不允许我们将它用于其他任何事情,并且因为它有时允许更快地复制类型。

虚拟基类以外的成员的放置定义为:

从偏移量 dsize(C) 开始,如果需要对齐基类的 nvalign(D) 或数据成员的 align(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()- 它不关心尾部填充,因为它假设它不存在。

于 2018-12-18T18:46:08.640 回答
1
FooBefore derived;
FooBeforeBase src, &dst=derived;
....
memcpy(&dst, &src, sizeof(dst));

如果额外的数据成员被放置在洞中,memcpy就会覆盖它。

正如评论中正确指出的那样,该标准不要求此memcpy调用应该起作用。然而,Itanium ABI 的设计似乎考虑到了这种情况。也许以这种方式指定 ABI 规则是为了使混合语言编程更加健壮,或者为了保持某种向后兼容性。

相关的 ABI 规则可以在这里找到。

可以在此处找到相关答案 (此问题可能与该问题重复)。

于 2018-12-18T17:14:51.580 回答
-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) );

这是未定义的行为。

于 2018-12-18T16:59:46.357 回答
-1

FooBefore也不是标准布局;两个类声明非静态数据成员(FooBeforeFooBeforeBase)。因此允许编译器任意放置一些数据成员。因此,出现了不同工具链上的差异。在标准布局层次结构中,至多一个类(最派生类或至多一个中间类)应声明非静态数据成员。

于 2018-12-18T18:41:53.917 回答
-2

这是与nm的答案类似的情况。

首先,让我们有一个函数,它清除 a 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 所做的优化。

于 2018-12-18T17:49:56.737 回答