16

正如这个答案中提到的,简单地第二次调用析构函数已经是未定义的行为 12.4/14(3.8)。

例如:

class Class {
public:
    ~Class() {}
};
// somewhere in code:
{
    Class* object = new Class();
    object->~Class();
    delete object; // UB because at this point the destructor call is attempted again
}

在这个例子中,类的设计方式是可以多次调用析构函数——不会发生双重删除之类的事情。内存仍然在delete被调用的地方分配——第一个析构函数调用不调用::operator delete()释放内存。

例如,在 Visual C++ 9 中,上面的代码看起来可以工作。甚至 C++ 对 UB 的定义也没有直接禁止符合 UB 条件的东西工作。因此,上面的代码需要破坏一些实现和/或平台细节。

为什么上面的代码会在什么条件下中断?

4

16 回答 16

14

我认为您的问题针对标准背后的基本原理。反过来想一想:

  1. 定义两次调用析构函数的行为会产生工作,可能会产生很多工作。
  2. 您的示例仅表明,在某些微不足道的情况下,调用析构函数两次不会有问题。这是真的,但不是很有趣。
  3. 当两次调用析构函数时,你没有给出一个令人信服的用例(我怀疑你可以)在任何方面都是一个好主意/使代码更容易/使语言更强大/清理语义/或其他任何东西。

那么为什么这不会再次导致未定义的行为呢?

于 2010-05-05T08:44:36.463 回答
8

标准中公式化的原因很可能是其他一切都会复杂得多:它必须定义何时可以精确地进行双重删除(或相反)——即使用微不足道的析构函数或使用可以丢弃副作用的析构函数。

另一方面,这种行为没有任何好处。在实践中,您无法从中获利,因为您通常无法知道类析构函数是否符合上述标准。没有通用代码可以依赖于此。以这种方式引入错误将非常容易。最后,它有什么帮助?它只是使得编写不跟踪其对象的生命周期的草率代码成为可能——换句话说,未指定的代码。为什么标准应该支持这一点?


现有的编译器/运行时会破坏您的特定代码吗?可能不会——除非他们有特殊的运行时检查来防止非法访问(防止看起来像恶意代码的东西,或者只是泄漏保护)。

于 2010-05-05T09:03:06.543 回答
8

调用析构函数后对象不再存在。

因此,如果您再次调用它,您将调用一个不存在的对象上的方法。

为什么这会被定义为行为?出于调试/安全/某种原因,编译器可以选择将已被破坏的对象的内存清零,或者将其内存与另一个对象一起回收作为优化,或其他。实现可以随心所欲。再次调用析构函数本质上是在任意原始内存上调用一个方法——一个坏主意(tm)。

于 2010-05-05T09:44:07.613 回答
4

当你使用 C++ 的工具来创建和销毁你的对象时,你同意使用它的对象模型,但是它是被实现的。

一些实现可能比其他实现更敏感。例如,交互式解释环境或调试器可能会更努力地进行内省。这甚至可能包括特别提醒您双重破坏。

有些对象比其他对象更复杂。例如,具有虚拟基类的虚拟析构函数可能有点麻烦。如果我没记错的话,对象的动态类型会随着一系列虚拟析构函数的执行而改变。这很容易导致最后的无效状态。

声明正确命名的函数以使用而不是滥用构造函数和析构函数很容易。面向对象的直接 C 在 C++ 中仍然是可能的,并且可能是某些工作的正确工具……无论如何,析构函数并不是每个与销毁相关的任务的正确构造。

于 2010-05-05T09:02:51.293 回答
3

Class如果您调用析构函数两次,以下内容将在我的机器上的 Windows 中崩溃:

class Class {
public:
    Class()
    {
        x = new int;
    }
    ~Class() 
    {
        delete x;
        x = (int*)0xbaadf00d;
    }

    int* x;
};

我可以想象一个实现,它会因微不足道的析构函数而崩溃。例如,这样的实现可以从物理内存中删除被破坏的对象,并且对它们的任何访问都会导致一些硬件故障。看起来 Visual C++ 不是这种实现之一,但谁知道呢。

于 2010-05-05T08:34:16.797 回答
3

析构函数不是常规函数。调用一个不是调用一个函数,而是调用多个函数。它是析构函数的魔力。虽然您提供了一个简单的析构函数,其唯一目的是使其难以显示它可能会如何破坏,但您未能演示被调用的其他函数的作用。标准也没有。在那些功能中,事情可能会分崩离析。

作为一个简单的例子,假设编译器插入代码来跟踪对象生命周期以进行调试。构造函数 [它也是一个神奇的函数,可以做你没有要求它做的各种事情] 将一些数据存储在某个地方,上面写着“我在这里”。在调用析构函数之前,它会将数据更改为“我走了”。调用析构函数后,它会删除用于查找该数据的信息。所以下次你调用析构函数时,你最终会遇到访问冲突。

您可能还想出涉及虚拟表的示例,但是您的示例代码不包含任何虚拟函数,因此这是作弊。

于 2010-05-05T09:00:53.257 回答
2

标准 12.4/14

一旦为对象调用析构函数,该对象就不再存在;如果为生命周期已结束的对象调用析构函数(3.8),则行为未定义。

我认为本节是指通过删除调用析构函数。换句话说:本段的要点是“两次删除对象是未定义的行为”。这就是为什么您的代码示例可以正常工作的原因。

然而,这个问题是相当学术的。析构函数旨在通过删除调用(除了通过正确观察到的placement-new分配的对象除外)。如果你想在析构函数和第二个函数之间共享代码,只需将代码提取到一个单独的函数并从你的析构函数中调用它。

于 2010-05-05T08:36:58.537 回答
1

由于您真正要求的是一个看似合理的实现,您的代码将在其中失败,假设您的实现提供了一种有用的调试模式,它跟踪所有内存分配以及对构造函数和析构函数的所有调用。因此,在显式调用析构函数之后,它会设置一个标志来表示对象已被销毁。delete检查此标志并在程序检测到代码中存在错误的证据时停止程序。

为了使您的代码按预期“工作”,此调试实现必须对您的无操作析构函数进行特殊处理,并跳过设置该标志。也就是说,它必须假设你故意破坏两次,因为(你认为)析构函数什么都不做,而不是假设你不小心破坏了两次,但是因为析构函数碰巧什么都不做而未能发现错误. 要么你粗心大意,要么你是一个反叛者,在调试实现中帮助那些粗心的人比迎合反叛者有更多的里程;-)

于 2010-05-05T11:21:07.377 回答
1

可能破坏的实现的一个重要示例:

符合标准的 C++ 实现可以支持垃圾收集。这是一个长期的设计目标。GC 可能假设一个对象可以在其 dtor 运行时立即被 GC。因此,每个 dtor 调用都会更新其内部 GC 簿记。第二次为同一个指针调用 dtor 时,GC 数据结构很可能会损坏。

于 2010-05-06T09:39:33.597 回答
0

根据定义,析构函数“销毁”对象并两次销毁对象是没有意义的。

您的示例有效,但通常很难

于 2010-05-05T08:29:45.323 回答
0

我猜它已被归类为未定义,因为大多数双重删除都是危险的,并且标准委员会不想在相对少数不需要的情况下为标准添加例外。

至于您的代码可能在哪里中断;您可能会在某些编译器的调试版本中发现代码中断;许多编译器在发布模式下将 UB 视为“对明确定义的行为不会影响性能的事情”,在调试版本中将其视为“插入检查以检测不良行为”。

于 2010-05-05T08:40:40.100 回答
0

基本上,正如已经指出的那样,对于任何执行工作的类析构函数,第二次调用析构函数都会失败。

于 2010-05-05T08:42:52.713 回答
0

这是未定义的行为,因为标准明确了析构函数的用途,并且没有决定如果您使用不正确会发生什么。未定义的行为并不一定意味着“崩溃”,它只是意味着标准没有定义它,所以它留给实现。

虽然我对 C++ 不太熟悉,但我的直觉告诉我,欢迎实现将析构函数视为另一个成员函数,或者在调用析构函数时实际销毁对象。所以它可能会在某些实现中中断,但在其他实现中可能不会。谁知道呢,它是不确定的(如果你尝试的话,当心恶魔飞出你的鼻子)。

于 2010-05-05T08:46:00.400 回答
0

它是未定义的,因为如果不是,每个实现都必须通过一些元数据来标记对象是否仍然存在。您必须为违反基本 C++ 设计规则的每个对象支付该成本。

于 2010-05-05T09:50:43.900 回答
-1

原因是因为您的类可能是例如引用计数的智能指针。所以析构函数递减引用计数器。一旦该计数器达到 0,则应清理实际对象。

但是如果你调用析构函数两次,那么计数就会混乱。

其他情况也有同样的想法。也许析构函数将 0 写入一块内存,然后释放它(这样您就不会不小心将用户的密码留在内存中)。如果您尝试再次写入该内存 - 在它被释放后 - 您将遇到访问冲突。

对象被构造一次并被破坏一次才有意义。

于 2010-05-05T08:45:08.217 回答
-1

原因是,如果没有该规则,您的程序将变得不那么严格。更加严格——即使它没有在编译时强制执行——也是好的,因为作为回报,您可以获得对程序行为方式的更多可预测性。当类的源代码不受您控制时,这一点尤其重要。

很多概念:RAII、智能指针和一般的内存分配/释放都依赖于这个规则。调用析构函数的次数(一)对他们来说至关重要。因此,此类事情的文档通常会承诺:“根据 C++ 语言规则使用我们的类,它们将正常工作!

如果没有这样的规则,它会说“根据 C++ 语言规则使用我们的类,是的,不要调用它的析构函数两次,然后它们会正常工作。 ”很多规范听起来都是这样。这个概念对于语言来说太重要了,不能在标准文档中跳过它。

就是原因。与二进制内部无关(在Potatoswatter 的回答中有描述)。

于 2010-05-05T09:41:42.923 回答