为啥在 C++ 中第二次调用析构函数未定义的行为?

Posted

技术标签:

【中文标题】为啥在 C++ 中第二次调用析构函数未定义的行为?【英文标题】:Why exactly is calling the destructor for the second time undefined behavior in C++?为什么在 C++ 中第二次调用析构函数未定义的行为? 【发布时间】:2011-02-15 19:50:00 【问题描述】:

正如this answer 中提到的,简单地第二次调用析构函数已经是未定义的行为 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 条件的东西工作。因此,上面的代码需要破坏一些实现和/或平台细节。

为什么上面的代码会在什么情况下中断?

【问题讨论】:

好问题.....也许为什么我们不应该显式调用析构函数:D 它会在它中断的地方“中断”。我觉得这个问题毫无意义。 @Neil Butterworth:问题在于具体需要哪些实现细节。 它未定义,因为委员会无法猜测实现将如何实现删除操作。他们也不想浪费时间去猜测可能的实施方式。 “因此,上面的代码需要破坏一些实现和/或平台细节。”不,不是。您不能指望标准委员会列举每种可能的析构函数类型,并指定在某些实现下哪些可能会破坏,哪些可能不会破坏。 “不要对死物做任何事情”比“除非你知道它是安全的,否则不要对死物做任何事情”有用得多。 【参考方案1】:

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

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

那么为什么这又会导致未定义的行为呢?

【讨论】:

@sharptooth:这有什么关系?该标准的基本原理不是“我们可以想象一个会破坏的实现”,而只是“我们让每个人的生活更轻松,并通过告诉你编写一致的代码来减少程序员错误的范围”。【参考方案2】:

标准中制定的原因很可能是其他所有内容会复杂得多:它必须定义何时可以进行双重删除(或相反)——即使用一个普通的析构函数或使用一个副作用可以被丢弃的析构函数。

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


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

【讨论】:

我了解标准不想支持它并将其命名为 UB。但是在什么情况下,带有微不足道的析构函数的代码会中断? @sharptooth:见更新。请注意,我可以轻松想象这样的运行时检查。代码分析工具(如 Valgrind)也可能会抱怨(如果您将其视为“中断”——我确实如此)。 @sharptooth:可能不会。但是双重删除是(根据规范)非法内存访问,并且可能会对此类访问进行全面检查,因为其他非法内存访问可以启用恶意代码。【参考方案3】:

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

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

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

【讨论】:

在显式的析构函数调用之后没有“回收”。编译器必须假设内存将被用户重用。【参考方案4】:

当您使用 C++ 的工具来创建和销毁您的对象时,您同意使用它的对象模型,不管它是如何实现的。

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

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

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

【讨论】:

我添加了一个涉及一些相同术语的答案。您确实没记错:在析构函数序列的执行过程中,对象的动态类型从最派生到层次结构的根。 +1 用于析构函数。在 GCC 中,析构函数确实有时会重写 vcall 偏移量和指向 vtables 的指针;这最终导致了一个破碎的状态。被破坏的物体看起来就像被分解成小块一样,不能再作为一个整体发挥作用。【参考方案5】:

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

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

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

【讨论】:

你看,阿列克谢大师?? :)【参考方案6】:

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

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

    int* x;
;

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

【讨论】:

我相信即使没有测试 - 当 delete 将在无效指针上被调用时,它会崩溃。但在我的示例中,析构函数是微不足道的。 这不是由于两次调用析构函数,而是由于两次删除 x @Carson Myers:这不是双重删除x,而是第一次删除x,第二次删除0xbaadf00d。 我想,基本效果一样。 您的意思是该对象将从程序的地址空间中取消映射,但在调用operator delete 之前不会“释放”内存?那么我可以使用operator new 为我的目的分配原始内存吗?【参考方案7】:

标准 12.4/14

一旦一个析构函数被调用 对象,对象不再存在; 如果 为对象调用析构函数 其生命周期已结束 (3.8)。

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

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

【讨论】:

该段落的意思与它所说的完全一样,并且通常在不使用 delete 的情况下调用析构函数 - 无论是针对堆栈上的对象还是通过显式的析构函数调用。 这或多或少正是我对原始问题的回答(从这个问题链接到),这个问题是关于 为什么 实现会中断(答案是't:“因为标准是这样说的”) 有正当理由明确调用析构函数,所以你的最后一段没有意义。 实际上,如果分配内存并调用placement-new,则必须显式调用析构函数。问题更多是关于在仍然分配内存时如何“对象不再存在”。 @Adrian Grigore:如果您使用placement-new 创建了对象,则需要显式调用析构函数。【参考方案8】:

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

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

【讨论】:

【参考方案9】:

一个可能中断的实现的重要示例:

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

【讨论】:

【参考方案10】:

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

您的示例有效,但很难普遍有效

【讨论】:

【参考方案11】:

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

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

【讨论】:

【参考方案12】:

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

【讨论】:

【参考方案13】:

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

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

【讨论】:

一个对象的析构函数永远不会破坏那个对象——它只是在它的内存被其他方式回收之前清理它(例如,如果它是一个动态分配的对象,则通过operator delete)。【参考方案14】:

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

【讨论】:

【参考方案15】:

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

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

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

对象构造一次,销毁一次才有意义。

【讨论】:

析构函数的副作用,例如“递减引用计数器”,是用户的问题。他们不关心代码语言。 (destroy 成员函数也会出现同样的问题。)【参考方案16】:

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

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

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

就是原因。与二进制内部无关(在Potatoswatter's answer 中有描述)。

【讨论】:

RAII、智能指针等都可以在析构函数在调用两次时具有明确定义的行为的环境中实现。实施它们时只需要额外的工作。 @Dennis,在实现它们的同时——以及在实现其他类的全部负载的同时。这就是规则存在的原因——它方便、富有成效,并且可以让您免于不必要的工作!

以上是关于为啥在 C++ 中第二次调用析构函数未定义的行为?的主要内容,如果未能解决你的问题,请参考以下文章

在破坏调用期间从另一个线程未定义的行为调用对象上的方法?

为啥在已删除指针上调用非虚拟成员函数是未定义的行为?

我可以通过引用调用placement-new和析构函数吗?

C++:对象和类|| 类的构造函数与析构函数

C++:对象和类|| 类的构造函数与析构函数

Express,第二次调用then()函数,用猫鼬保存对象后返回未定义对象