【问题标题】:Why isn't it undefined behaviour to destroy an object that was overwritten by placement new?为什么销毁被放置 new 覆盖的对象不是未定义的行为?
【发布时间】:2019-02-08 17:49:00
【问题描述】:

我试图弄清楚以下是否是未定义的行为。我感觉它不是 UB,但我对标准的阅读使它看起来像是 UB:

#include <iostream>

struct A {
    A() { std::cout << "1"; }
    ~A() { std::cout << "2"; }
};

int main() {
    A a;
    new (&a) A;
}

引用 C++11 标准:

basic.life¶4 说“程序可以通过重用对象占用的存储空间来结束任何对象的生命周期”

所以在new (&amp;a) A 之后,原来的A 对象已经结束了它的生命周期。

class.dtor¶11.3 表示“当创建对象的块退出 ([stmt.dcl]) 时,对具有自动存储持续时间 ([basic.stc.auto]) 的构造对象隐式调用析构函数”

所以当main 退出时,原始A 对象的析构函数被隐式调用。

class.dtor¶15 表示“如果为生命周期已结束 ([basic.life]) 的对象调用析构函数,则行为未定义。”

所以这是未定义的行为,因为原来的 A 不再存在(即使新的 a 现在存在于同一个存储中)。

问题是是否调用了原始A 的析构函数,或者是否调用了当前名为a 的对象的析构函数。

我知道basic.life¶7,它说名称a 指的是放置new 之后的新对象。但是 class.dtor¶11.3 明确表示它是 退出范围的对象的析构函数 被调用,而不是由退出范围的名称引用的 对象的析构函数 .

我是误读了标准,还是这实际上是未定义的行为?

编辑:有几个人告诉我不要这样做。澄清一下,我绝对不打算在生产代码中这样做!这是针对CppQuiz 的问题,这是关于极端案例而不是最佳实践的问题。

【问题讨论】:

  • 找到另一个同意的答案:stackoverflow.com/a/35395517/3980929(可能不那么受骗,但仍然很有趣)
  • 您不应在现有对象上使用新放置,因为先前构造的对象不会被破坏。如果通过运行上述程序,输出是 '112' 或 1122,那么它是未定义的行为。您只需要一些想象力,例如如果 A 中有一个指针(可能是间接的)会发生什么,并且您可以猜测它是未定义的行为。
  • @Phil1970 [basic.life]p5 would like to have a word with you。此外,它说如果输出为 1122,则编译器有错误,因为标准明确禁止这样做!
  • @Rakete1111 我在那个问题中看不到任何与这个问题相关的内容,除了这是一个非常广泛的问题,它询问了关于放置新位置的所有内容else - 关于那个仅凭我认为这是一个糟糕的欺骗目标 tbh
  • 标准要求它以这种方式发生,但我同意可以更好地解释它。 Peter's comment 中的规则必须存在的原因是因为否则会混淆“被覆盖对象的现有指针/引用/变量名现在引用新对象”是否包含析构函数调用。

标签: c++ language-lawyer undefined-behavior object-lifetime placement-new


【解决方案1】:

你误读了。

“为构造的对象隐式调用析构函数”……表示那些存在并且它们的存在已经达到完全构造的程度。尽管可以说没有完全说明,但原始的A 不符合这个标准,因为它不再“构造”:它根本不存在!只有新的/替换的对象会自动销毁,然后在main 的末尾,如您所料。

否则,这种形式的放置 new 将非常危险,并且在语言中具有值得商榷的价值。然而,值得指出的是,以这种方式重新使用实际的A 有点奇怪和不寻常,如果没有其他原因导致这种问题。通常,您会将placement-new 放入一些平淡无奇的缓冲区(如char[N] 或一些对齐的存储),然后自己也调用析构函数。

类似于你的例子的东西实际上可以在basic.life¶8找到——它是UB,但只是因为有人在B之上构建了一个T;措辞清楚地表明这是代码的唯一问题。

但关键是:

本国际标准中赋予对象的属性仅在其生命周期内适用于给定对象。 [..] [basic.life¶3]

【讨论】:

  • 很好的发现。您提到的位置实际上包含宽恕 OP 特定情况的措辞:“当发生隐式析构函数调用时,程序必须确保原始类型的对象占据相同的存储位置;否则的行为程序未定义。”由我强调。注意“原始类型”,而不是“原始对象”;-)。
  • @PeterA.Schneider 这并没有真正告诉我们原始对象现在可能会发生什么,但它不需要:)
  • 那么“已构建”的意思是“已构建且其生命周期尚未结束”?另外,您认为导致调用析构函数的确切原因是什么? 1:名称a 超出范围,现在指代新对象,或者 2:新对象本身超出范围?
  • 我同意这是不寻常的顺便说一句。这是用户在 cppquiz.org 上提出的问题。并且该站点不应被视为最佳实践的来源! ;)
  • @knatten 名称具有范围,对象(标准中的变量是对象)具有生命周期;-)。对象的生命周期结束(因为它的内存正在被重用)。不过,还没有什么超出范围......名称a(仍在范围内)指的是一个新对象,它的生命周期在它被放置新创建时开始。该标准经常使用“变量”这个词,在我的书中它是名称和可写对象的组合;在这些重用案例中,连接被切断,我们必须区分两者。任何地方都有“变量”的定义吗?
【解决方案2】:

评论太长了。

Lightness 的回答是正确的,他的链接是正确的参考。

但让我们更准确地研究一下术语。有

  • “存储持续时间”,与内存有关。
  • “生命周期”,与对象有关。
  • “范围”,涉及名称。

对于自动变量,这三个都重合,这就是为什么我们经常不能清楚地区分:“变量超出范围”。即:名称超出范围;如果是具有自动存储期限的对象,则调用析构函数,结束命名对象的生命周期;最后释放内存。

在您的示例中,只有 name scopestorage duration 重合——在其存在期间的任何时候,名称 a 指的是有效内存——而 object生命周期在同一内存位置和同名a 的两个不同对象之间拆分。

不,我认为您不能将 11.3 中的“构造”理解为“完全构造且未销毁”,因为如果对象的生命周期被在显式析构函数调用之前。 事实上,这是内存重用概念的问题之一:如果新对象的构造因异常而失败,则范围将被保留,并且将尝试在不完整的对象或旧对象上调用析构函数已经被删除了。

我想您可以想象自动分配的类型化内存标有“待销毁”标签,该标签在堆栈展开时进行评估。除了这个简单的概念之外,C++ 运行时并没有真正跟踪单个对象或它们的状态。由于变量名称基本上是常量地址,因此可以方便地想到“名称超出范围”会触发对假定类型的命名对象的析构函数调用,该类型假定存在于该位置。如果这些假设之一是错误的,那么所有的赌注都将被取消。

【讨论】:

  • 感谢您的澄清!我同意这可能是标准的意图。尽管从@Rakete1111 的回答中可以看出,我认为标准对此并不清楚。我也同意你关于“构建”的评论,我只是想理解 Lightness 在说什么,因为我认为这就是他的意思。
【解决方案3】:

我是误读了标准,还是这实际上是未定义的行为?

这些都不是。标准不清楚,但可能更清楚。意图是调用新对象的析构函数,正如 [basic.life]p9 中所暗示的那样。

[class.dtor]p12 不是很准确。我向 Core 询问了此事,Mike Miller (a very senior member) said:

我不会说这是矛盾的 [[class.dtor]p12 与 [basic.life]p9],但肯定需要澄清。析构函数描述写得有点天真,没有考虑到占用一点自动存储的原始对象可能已被占用相同自动存储位的不同对象替换,但意图是如果在该对象上调用构造函数用于在其中创建对象的自动存储位-即,如果控制流经该声明-则将为假定在退出块时占用该位自动存储的对象调用析构函数-即使它不是“相同的”由构造函数调用创建的对象。

我将在 CWG 问题发布后立即更新此答案。所以,你的代码没有UB。

【讨论】:

  • 谢谢! 正是让我感到困惑的地方。在对这个问题的所有各种回复和 cmet 以及我们之前的电子邮件交流之后,我开始认为有人应该对 [class.dtor] p12 提出澄清。所以我很高兴听到这个消息! :) 现在我今晚终于可以睡觉了。
  • @knatten 公平地说,经过一番思考,我发现它也完全不清楚(接近矛盾),所以感谢您在这里提问:)
  • 我仍然认为 [basic.life]p3 很明显。
  • @Light 该段没有说/暗示调用了新对象的析构函数。
  • @Rakete1111 不,但它说一旦所述对象的生命周期结束,“对象”发生的任何规则都不会适用,通过它我们可以理解没有对旧的析构函数调用,死物(老实说,到底为什么会有?)。其余部分告诉我们,a 下的 now 仍然存在正常的析构函数调用。
【解决方案4】:

想象一下使用placement new 为A a 对象所在的存储创建一个struct B。在作用域的末尾,将调用struct A 的析构函数(因为A 类型的变量a 超出了作用域),即使B 类型的对象现在确实存在于其中。

如前所述:

"如果程序以静态结束 T 类型对象的生命周期 ([basic.stc.static])、线程 ([basic.stc.thread]) 或自动 ([basic.stc.auto]) 存储持续时间,如果 T 有一个非平凡的 析构函数,39 程序必须确保原来的对象 当隐式析构函数时,类型占用相同的存储位置 通话发生;"

所以在将B 放入a 存储后,您需要销毁B 并再次将A 放在那里,以免违反上述规则。这在某种程度上并不直接适用于此,因为您将A 放入A,但它显示了行为。它表明,这种想法是错误的:

因此,原始 A 对象的析构函数在以下情况下被隐式调用 主要出口。

不再有“原始”对象。在a 的存储中只有一个对象当前处于活动状态。就是这样。并且在当前位于a中的数据上,调用了一个函数,即A的析构函数。这就是程序编译成的。如果它能够神奇地跟踪所有“原始”对象,那么您将以某种方式拥有动态运行时行为。

另外:

程序可以通过重用存储来结束任何对象的生命周期 对象占用或显式调用析构函数 具有非平凡析构函数的类类型的对象。对于一个对象 具有非平凡析构函数的类类型,程序不是 需要在存储之前显式调用析构函数 占用的对象被重用或释放;但是,如果没有 显式调用析构函数或删除表达式 ([expr.delete]) 不用于释放存储,析构函数 不应被隐式调用,并且任何依赖于 析构函数产生的副作用具有未定义的行为。

由于A 的析构函数不是微不足道的并且有副作用,(我认为)它的未定义行为。对于内置类型,这不适用(因此您可以将 char 缓冲区用作对象缓冲区,而无需在使用后将 char 重建回缓冲区),因为它们有一个微不足道的(无操作)析构函数。

【讨论】:

  • 我知道已经没有原始对象了,这实际上就是我首先发布这个问题的原因。我对 class.dtor¶11.3 的解读是,实现应该破坏原始对象。该对象不再存在,因此阅读显然是错误的。似乎 cwg 同意措辞不准确,需要更新(请参阅@Rakete1111 的回答)。
  • 至于您的“附加”段落,我认为他们的意思是,当您重用存储时,实现不需要隐式调用析构函数。然而,标准的其他部分说(或至少试图说)当创建a 的块退出时将调用析构函数,并且该析构函数现在将销毁新的A。所以我认为这里没有任何 UB。
  • @knatten 它保证在范围退出时调用结构 A 的析构函数,因为 A 类型的变量超出范围。我们有责任确保在发生这种情况的那一刻有一个正确的 A 类型的对象存在(否则它的未定义行为)。我想我们都同意这一点。现在到破坏“原始”A 对象的部分。当您运行程序时,您甚至只看到 1 个析构函数调用但 2 个构造函数调用。你可能会侥幸成功,因为A 是一个超级简单的结构。
  • @knatten ...例如,想象一下有一个带有线程成员的结构。您不能只在其上复制一个新对象(这就是 placemenet new 基本上所做的)。在重用存储之前,您必须手动调用析构函数。
  • @curiousguy 我不明白你的意思
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-09-06
  • 2015-07-14
  • 2012-01-05
  • 1970-01-01
  • 1970-01-01
  • 2020-04-10
相关资源
最近更新 更多