显式调用析构函数。

24

我偶然发现了以下代码片段:

#include <iostream>
#include <string>
using namespace std;
class First
{
    string *s;
    public:
    First() { s = new string("Text");}
    ~First() { delete s;}
    void Print(){ cout<<*s;}
};

int main()
{
    First FirstObject;
    FirstObject.Print();
    FirstObject.~First();
}

这段文字说这个代码片段应该会导致运行时错误。但我并不确定,于是我尝试编译和运行它。它能够正常工作。奇怪的是,尽管涉及的数据很简单,程序在打印“Text”后有所迟滞,仅在一秒钟后才完成。

由于不确定显式调用析构函数是否合法,我添加了一个字符串以便在析构函数中打印出来。程序打印了两次该字符串。因此我的猜测是,当程序正常结束时不知道显式调用,尝试再次销毁该对象,因此析构函数被调用两次。

简单的搜索确认了,在自动化对象上显式调用析构函数是危险的,因为第二次调用(当对象超出其范围时)具有未定义的行为。因此我很幸运,我的编译器(VS 2017)或者这个特定的程序可能实现了某种防护机制来避免这种情况发生。

这段文字中提到的运行时错误是不是真的存在呢?还是这种情况很常见?或者也许我的编译器实现了某种防护机制以避免这种情况发生?


21
C++标准从未保证运行时错误(总是未定义的行为),因此该文本肯定是错误的。 - UnholySheep
3
@UnholySheep,我不确定我会这样说。例如,异常离开一个noexcept函数是对std::terminate的保证调用,我会将其归类为运行时错误。 - chris
1
运行时错误肯定会发生:析构函数被调用两次,这是一个错误,并且它发生在运行时。我敢打赌,如果你在调试模式下运行测试,你就可以“捕捉”它。为什么没有弹出消息?当VC2017终止你的应用程序并知道它最后要做的两件事情是删除相同的指针时,它在发布模式下做了什么?是否有一些优化通过错误来隐藏/修复你的错误?你应该向微软支持部门询问... - L.C.
@MatthieuBrucher 我认为他们在析构函数中添加了一个打印语句。 - Solomon Ucko
@MatthieuBrucher你说过“它会释放两次”。但现在回头看,你是在谈论成员变量s,而不是对象本身。我的错误。 - Mark Ransom
显示剩余11条评论
4个回答

36

一项简单的搜索证实,显式调用自动对象的析构函数很危险,因为第二次调用(当对象超出范围时)会导致未定义的行为。

确实如此。如果您使用自动存储销毁一个对象,将会引发未定义行为。了解更多信息

所以我对我的编译器(VS 2017)或这个特定程序很幸运。

我认为您是不幸的。对于未定义行为而言,最好(对于您作为开发人员来说)的状况就是首次运行时崩溃。如果它似乎可以正常工作,则可能会在2038年1月19日的生产环境中崩溃。

这段话中的文本是否只是错误的?或者运行时错误真的很普遍吗?或者我的编译器实现了某种针对此类情况的预防机制吗?

是的,这段文字有些错误。 未定义行为是未定义的。运行时错误只是许多可能性之一(包括鼻子恶魔)。

关于未定义行为的好文章:什么是未定义行为?


5
当你显式销毁具有自动存储的对象时,会触发未定义行为。更确切地说,当对象在其自然寿命结束时未被再生而达到末尾时才会触发。析构函数本身并不是未定义行为。 - StoryTeller - Unslander Monica
3
更准确地说,2038年1月19日03:14:08 UTC :D - George Spatacean
@StoryTeller 是的,我想避免具体细节。我已经修复了它。 - YSC

17
这只是草案C++标准中的未定义行为(undefined behavior)[class.dtor]p16。一旦对象的析构函数被调用,对象就不存在了;如果对已经结束其生命周期([basic.life])的对象调用析构函数,则行为是未定义的。我们可以从未定义行为的定义中看到:这份文件没有对该行为提出任何要求。因此,您不能对结果有任何期望,尽管在特定编译器、特定选项和特定机器上作者可能会得到相应的结果,但我们不能期望它是可移植或可靠的结果。尽管有时实现会尝试获得一个特定的结果,但这只是另一种可接受的未定义行为形式。此外,[class.dtor]p15给出了更多关于我引用的规范部分的上下文。请注意:极少需要显式调用析构函数。这样调用的一个用途是使用放置new表达式放置在特定地址的对象。这种显式放置和销毁对象的用法可能是必要的,以应对专用硬件资源和编写内存管理工具。例如,
void* operator new(std::size_t, void* p) { return p; }
struct X {
  X(int);
  ~X();
};
void f(X* p);

void g() {                      // rare, specialized use:
  char* buf = new char[sizeof(X)];
  X* p = new(buf) X(222);       // use buf[] and initialize
  f(p);
  p->X::~X();                   // cleanup
}

10

这段文本是否对运行时错误做出了简单的错误描述?

是的,描述有误。

或者说运行时错误真的很常见吗?或者我的编译器实现了一些防范措施来避免这种情况发生?

你无法确定,这就是当你的代码引发未定义行为(Undefined Behavior)时会发生的事情;你不知道执行它时会发生什么。

在你的情况下,你可能很幸运,代码可以正常运行,但对于我来说,它会导致一个错误(双重释放)。


*因为如果你收到了错误信息,你会开始调试,否则,在一个大型项目中,你可能会错过它...


0

这个问题已经有一段时间了,但我认为我可以再做出一些贡献。

首先,当一个对象超出范围时,它的析构函数会被调用。在这里,您明确调用了析构函数一次,因此它会被再次调用。

第二次时,它会再次删除已经被释放的内部 s 指针。这会破坏内部内存结构,因此它现在可能会崩溃,也可能以后会崩溃。你不知道。

在释放数据之前,真正检查和清理数据总是一个好习惯。我会像这样编写析构函数:

~First() 
{ 
  if (s)
  {
  delete s;
  s = 0;
  }
}

那么你就可以避免这个错误。

手动调用析构函数并不一定是坏事,有时候是必要的。比如说你在嵌入式设备上工作,你不想一直使用动态分配内存。在启动时,你可以分配一大块内存,然后在其上构造/销毁对象。

假设你有一个原始指针,指向那个大块内存中的某个原始缓冲区,你将使用它作为你的第一个对象。该缓冲区的大小必须至少为 sizeof(First)。

当你想要构建对象时,你使用放置 new :

new(p) First();

当你想要销毁它时,你调用:

p->~First();

这样你就永远不会释放内存,只是在预先分配的缓冲区上构造/销毁对象。

提醒一下,关于 new/delete 的作用:

new 将分配内存,然后调用构造函数。 delete 将调用析构函数,然后释放内存。

如果你已经有了内存,你可以只调用构造函数和析构函数,而不需要分配或释放。

话虽如此,如果你重新构建代码,它也可以工作:

int main()
{
    First FirstObject;
    FirstObject.Print();
    FirstObject.~First();
    new(&FirstObject) First(); // constructing the object a second time
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接