【问题标题】:PODs and inheritance in C++11. Does the address of the struct == address of the first member?C++11 中的 POD 和继承。 struct的地址是否==第一个成员的地址?
【发布时间】:2012-01-14 18:20:22
【问题描述】:

(我已经编辑了这个问题以避免分心。有一个核心问题需要在任何其他问题变得有意义之前得到澄清。向现在的答案似乎不太相关的任何人道歉。)

让我们设置一个具体的例子:

struct Base {
    int i;
};

没有虚方法,也没有继承,一般是一个很笨很简单的对象。因此它是Plain Old Data (POD),它依赖于可预测的布局。特别是:

Base b;
&b == reinterpret_cast<B*>&(b.i);

这是根据Wikipedia(它本身声称引用了C++03标准):

一个指向 POD-struct 对象的指针,使用 reinterpret cast 进行适当转换,指向它的初始成员,反之亦然,这意味着在 POD-struct 的开头没有填充。[8]

现在让我们考虑继承:

struct Derived : public Base {
};

同样,没有虚拟方法,没有虚拟继承,也没有多重继承。因此这也是 POD。

问题:这个事实(Derived 是 C++11 中的 POD)是否允许我们这样说:

Derived d;
&d == reinterpret_cast<D*>&(d.i); // true on g++-4.6

如果这是真的,那么下面的定义是明确的:

Base *b = reinterpret_cast<Base*>(malloc(sizeof(Derived)));
free(b); // It will be freeing the same address, so this is OK

我不是在这里询问newdelete - 更容易考虑mallocfree。我只是对像这种简单情况下派生对象布局的规定以及基类的初始非静态成员位于可预测位置的位置感到好奇。

Derived 对象应该等同于:

struct Derived { // no inheritance
    Base b; // it just contains it instead
};

事先没有填充?

【问题讨论】:

  • C++11 主要废除了“POD”术语,取而代之的是 standard-layouttrivially-copyable(至少所有有趣的要求使用这些更广泛的术语)。修正您的问题以使用正确的术语。
  • @BenVoigt,好主意。今晚有空时,我会尽力解决这个问题。
  • c++11 仍然有 POD,而且他的问题对于所使用的术语仍然有意义。
  • 另外,从“如果这是真的”开始的所有内容都应该是一个新问题,它与标题完全无关。

标签: c++ c++11 language-lawyer


【解决方案1】:

您不关心 POD 特性,您关心的是标准布局。这是来自标准第 9 节 [class] 的定义:

标准布局类是这样的类:

  • 没有非标准布局类(或此类类型的数组)或引用类型的非静态数据成员,
  • 没有虚函数 (10.3) 和虚基类 (10.1),
  • 对所有非静态数据成员具有相同的访问控制(第 11 条),
  • 没有非标准布局基类,
  • 要么在派生最多的类中没有非静态数据成员,并且最多有一个具有非静态数据成员的基类,要么没有具有非静态数据成员的基类,并且
  • 没有与第一个非静态数据成员相同类型的基类。

然后保证您想要的属性(第 9.2 节 [class.mem]):

指向标准布局结构对象的指针,使用reinterpret_cast 适当转换,指向其初始成员(或者如果该成员是位字段,则指向它所在的单元),反之亦然。

这实际上比旧要求更好,因为添加非平凡的构造函数和/或析构函数不会丢失reinterpret_cast 的能力。


现在让我们转到您的第二个问题。答案不是你所希望的。

Base *b = new Derived;
delete b;

是未定义的行为,除非 Base 具有虚拟析构函数。请参阅第 5.3.5 节 ([expr.delete])

在第一种选择(删除对象)中,如果要删除的对象的静态类型与其动态类型不同,则静态类型应为要删除的对象的动态类型的基类和静态类型的基类。类型应具有虚拟析构函数或行为未定义。


您之前使用mallocfree 的sn-p 大部分是正确的。这将起作用:

Base *b = new (malloc(sizeof(Derived))) Derived;
free(b);

因为指针b的值与placement new返回的地址相同,而后者又与malloc返回的地址相同。

【讨论】:

  • 我很清楚你的结束语。事实上,我在问题的开头也说了同样的话 :-) 就像我说的那样,StackOverflow 有一个先例,让社区意识到“未定义”有时可以非常明确地定义;您可能会发现我链接到的那个答案很有趣。我希望比“X 未定义”更能确信“Y,作为 X 的特例,是未定义的”。再次感谢您确认我的一些逻辑步骤。
  • "要么在派生最多的类中没有非静态数据成员,最多有一个具有非静态数据成员的基类,要么没有具有非静态数据成员的基类" ...所以这意味着一旦你有了继承,继承树中除了一个类之外的所有类都被允许拥有成员变量?
  • @Martin:继承树中只有一个类可以有非静态数据成员。 (通过归纳,因为基类也必须是标准布局)
  • @AaronMcDaid:如果它有效,那是因为您的编译器供应商提供了特定于实现的保证。根据标准,这是未定义的行为。因此,编译器供应商可以合法地为您显示的 sn-p 生成对 std::terminate 的调用。除非他们的文件另有承诺。
  • @Martin,是的,这看起来很糟糕。你从哪里得到的那句话? (我在不同的地方读过很多引文,但我已经记不清楚了:-/)
【解决方案2】:

大概你最后一段代码的意思是:

Base *b = new Derived;
delete b;  // delete b, not d.

在这种情况下,简短的回答是它仍然是未定义的行为。所讨论的类或结构是 POD、标准布局或可简单复制的事实并没有真正改变任何事情。

是的,你传递了正确的地址,是的,你和我都知道,在这种情况下,dtor 几乎是一个 nop——尽管如此,你传递给 delete 的指针具有不同的静态类型比动态类型,静态类型没有虚拟dtor。标准很清楚,这会产生未定义的行为。

从实际的角度来看,如果你真的坚持的话,你可能会摆脱 UB —— 很有可能你正在做的事情不会产生任何有害的副作用,至少对于大多数典型的编译器来说是这样。但是请注意,即使代码非常脆弱,所以看似微不足道的更改可能会破坏一切 - 甚至切换到具有非常繁重的类型检查的编译器,也可以这样做。 p>

就你的论点而言,情况非常简单:这基本上意味着委员会可能可以根据他们的意愿做出这种定义的行为。然而,据我所知,它从未被提出过,即使提出了,它也可能是一个非常低优先级的项目——它并没有真正增加太多,启用新的编程风格等等。

【讨论】:

  • +1 表示有问题的类或结构是 POD、标准布局或可简单复制并不会真正改变任何东西 - 特别是 POD/std 布局类似乎可以让 ctors/dtors 像您希望的那样复杂,如果 dtor 不是虚拟的,则运行时无法调用派生的 dtors。
  • @Martin:具有非平凡构造函数/析构函数的类不是平凡可复制的,也不是 POD。请不要混淆术语。
  • 标准通常很清楚 X 在各种上下文中是未定义的;但在其他情况下,从标准的其他部分可以清楚地看出,只有一个正确的 Y 实现(其中 Y 是 X 的子集),因此 Y 毕竟是明确定义的。 (这纯粹是理论上的,我不打算依赖这种行为。)
  • 整个 POD 的东西都在那里,因此通过 C++ 编译器运行的普通旧 C 代码不会不必要地中断。显然,任何调用 new 和 delete 的代码都不是普通的旧 C 代码。
【解决方案3】:

这是对Ben Voigt's answer'的补充,而不是替代。

您可能认为这只是技术问题。将其称为“未定义”的标准只是一些语义上的废话,除了允许编译器编写者无缘无故地做愚蠢的事情之外,它没有任何实际影响。但事实并非如此。

我可以看到理想的实现,其中:

Base *b = new Derived;
delete b;

导致了非常奇怪的行为。这是因为当编译器静态知道分配的内存块的大小时,存储它的大小有点愚蠢。例如:

struct Base {
};

struct Derived {
   int an_int;
};

在这种情况下,当调用delete Base 时,编译器完全有理由(因为您在问题开头引用的规则)相信指向的数据的大小是1,而不是4。如果例如,它实现了 operator new 的一个版本,它有一个单独的数组,其中 1 字节实体都密集打包,还有一个不同的数组,其中 4 字节实体都密集打包,它最终会假设 Base *指向 1 字节实体数组中的某个位置,而实际上它指向 4 字节实体数组中的某个位置,并因此产生各种有趣的错误。

我真的希望 operator delete 也被定义为采用大小,并且如果在具有非虚拟析构函数的对象上调用 operator delete 时,编译器会传入静态已知大小,或者传递已知大小的如果由于 virtual 析构函数而被调用,则指向的实际对象。尽管这可能会产生其他不良影响并且可能不是一个好主意(例如,如果在某些情况下调用 operator delete 而没有调用析构函数)。但这会使问题变得非常明显。

【讨论】:

  • 不是任何类型都可以重载operator new()operator delete() 使其合理显而易见吗?虽然我不得不承认,我也希望在operator delete() 中获得大小。使用分配器时,会传入适当的大小,在许多情况下可以避免这个问题,因为对象通常最好由某种容器分配。
【解决方案4】:

上面有很多关于不相关问题的讨论。是的,主要是为了 C 兼容性,只要您知道自己在做什么,您就可以依赖许多保证。但是,所有这些都与您的主要问题无关。主要问题是:是否存在可以使用与对象的动态类型不匹配的指针类型以及指向的类型没有虚拟析构函数的指针类型来删除对象的情况。答案是:不,没有。

这个逻辑可以从运行时系统应该做的事情推导出来:它得到一个指向对象的指针并被要求删除它。它需要存储有关如何调用派生类析构函数或对象实际占用的内存量的信息(如果要定义的话)。然而,这意味着在使用的内存方面可能会产生相当大的成本。例如,如果第一个成员需要非常严格的对齐,例如要像 double 一样在 8 字节边界处对齐,添加大小会增加至少 8 字节的开销来分配内存。尽管这听起来可能不算太糟糕,但这可能意味着只有一个对象而不是两个或四个对象适合缓存行,从而大大降低了性能。

【讨论】:

  • 是的,Ben,我认为这确实是我的逻辑中我心中有任何重大疑问的第一点。但是,我的猜测是很有可能有人会回答“基类,在单继承中,必须是派生对象中的初始实体,并且不能有任何预填充。”我明天可能会编辑问题。但是我需要完全消化我已经从所有答案中学到的东西。
  • 您想知道尺寸信息存储在哪里。但是请记住,当有虚拟析构函数时,这一切都定义得很好。我的猜测是,许多实现安排虚拟析构函数和/或 vtables 或其他东西来存储/返回“this”的真实值。因此,我不相信大小存储在任何地方(不超过 C 的 free 需要被告知大小)。在大多数实现中,为底层内存系统提供正确的地址(即 newed 的确切地址)就足够了,然后依赖于它自己的内部管理。
  • @AaronMcDaid 我意识到底层内存分配工具可以将大小存储在某处。但是,由于大小隐含在对象类型的大小中,或者如果存在虚拟析构函数,则可以从具体类型派生,因此即使底层内存分配也不需要跟踪大小!不幸的是,尽管delete 操作员知道它的大小,但在未将大小传递给operator delete() 时,这会发生故障。当然,如果知道使用了自己的运算符,实现仍然可以利用这一点。
  • 我不同意你的解释。虽然类型大小确实没有为静态分配的项目(全局/堆栈)存储,但动态分配的情况并非如此。动态分配通过内部内存管理器进行。 new / malloc 不向操作系统请求新内存,它从现有池中分配(必要时扩展)。为了跟踪空闲/使用的内存,分配的内存块通常会预先添加跟踪信息,例如下一个块指针和大小。理论上delete bfree(b) 拥有正常工作所需的所有信息。
  • 另外,析构函数不是释放器。我不知道为什么标准将“删除 b”定义为未定义的行为,因为 - 正如@Ben Voigt 所指出的那样,释放新位置不会有任何问题。当然,除非标准声明对新建(非放置)和删除进行一些特殊处理。我没有阅读标准,但我认为内存管理器的实现细节不是它的东西。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2023-04-01
  • 1970-01-01
  • 1970-01-01
  • 2019-09-30
  • 1970-01-01
  • 1970-01-01
  • 2021-02-15
相关资源
最近更新 更多