51

这个程序是否定义明确,如果没有,为什么?

#include <iostream>
#include <new>
struct X {
    int cnt;
    X (int i) : cnt(i) {}
    ~X() {  
            std::cout << "destructor called, cnt=" << cnt << std::endl;
            if ( cnt-- > 0 )
                this->X::~X(); // explicit recursive call to dtor
    }
};
int main()
{   
    char* buf = new char[sizeof(X)];
    X* p = new(buf) X(7);
    p->X::~X();  // explicit call to dtor
    delete[] buf;
}

我的推理:虽然两次调用析构函数是未定义的行为,但根据 12.4/14,它的确切含义是:

如果为生命周期已结束的对象调用析构函数,则行为未定义

这似乎并没有禁止递归调用。当对象的析构函数正在执行时,对象的生命周期还没有结束,因此再次调用析构函数不是 UB。另一方面,12.4/6 说:

在执行主体后 [...] 类 X 的析构函数调用 X 的直接成员的析构函数,X 的直接基类的析构函数 [...]

这意味着在从析构函数的递归调用返回后,所有成员和基类析构函数都将被调用,并且在返回上一层递归时再次调用它们将是 UB。因此,没有基类且只有 POD 成员的类可以在没有 UB 的情况下具有递归析构函数。我对吗?

4

5 回答 5

60

答案是否定的,因为 §3.8/1 中对“生命周期”的定义:

类型对象的生命周期在以下情况下T结束:

— 如果T是具有非平凡析构函数 (12.4) 的类类型,则析构函数调用开始,或者

——对象占用的存储空间被重用或释放。

一旦调用析构函数(第一次),对象的生命周期就结束了。因此,如果您从析构函数中调用对象的析构函数,则行为是未定义的,根据 §12.4/6:

如果为生命周期已结束的对象调用析构函数,则行为未定义

于 2010-06-17T16:01:50.443 回答
9

好的,我们知道行为没有定义。但是,让我们来看看真正发生的事情。我使用VS 2008。

这是我的代码:

class Test
{
int i;

public:
    Test() : i(3) { }

    ~Test()
    {
        if (!i)
            return;     
        printf("%d", i);
        i--;
        Test::~Test();
    }
};

int _tmain(int argc, _TCHAR* argv[])
{
    delete new Test();
    return 0;
}

让我们运行它并在析构函数中设置一个断点,让递归的奇迹发生。

这是堆栈跟踪:

替代文字

那是什么scalar deleting destructor?它是编译器在删除和我们的实际代码之间插入的东西。析构函数本身只是一个方法,没有什么特别之处。它并没有真正释放内存。它被释放在里面的某个地方scalar deleting destructor

我们去scalar deleting destructor看看拆解:

01341580  mov         dword ptr [ebp-8],ecx 
01341583  mov         ecx,dword ptr [this] 
01341586  call        Test::~Test (134105Fh) 
0134158B  mov         eax,dword ptr [ebp+8] 
0134158E  and         eax,1 
01341591  je          Test::`scalar deleting destructor'+3Fh (134159Fh) 
01341593  mov         eax,dword ptr [this] 
01341596  push        eax  
01341597  call        operator delete (1341096h) 
0134159C  add         esp,4 

在进行递归时,我们被困在 address 处01341586,而内存实际上只在 address 处被释放01341597

结论:在 VS 2008 中,由于析构函数只是一个方法,所有内存释放代码都注入到中间函数(scalar deleting destructor)中,因此递归调用析构函数是安全的。但这仍然不是一个好主意,IMO。

编辑:好的,好的。这个答案的唯一想法是看看当你递归调用析构函数时发生了什么。但是不要这样做,一般来说是不安全的。

于 2010-06-17T16:28:01.093 回答
5

它回到了编译器对对象生命周期的定义。如,何时真正取消分配内存。我认为在析构函数完成之前不可能,因为析构函数可以访问对象的数据。因此,我希望对析构函数的递归调用能够工作。

但是......肯定有很多方法可以实现析构函数和释放内存。即使它在我今天使用的编译器上按我想要的那样工作,我也会非常谨慎地依赖这种行为。有很多事情文档说它不起作用或者结果是不可预测的,如果你了解内部真正发生的事情,实际上工作得很好。但是除非你真的必须,否则依赖它们是不好的做法,因为如果规范说这不起作用,那么即使它确实起作用,你也不能保证它会在下一个版本中继续工作编译器。

也就是说,如果你真的想递归调用你的析构函数,这不仅仅是一个假设的问题,为什么不直接将析构函数的整个主体撕成另一个函数,让析构函数调用它,然后让它递归调用自己呢?那应该是安全的。

于 2010-06-17T17:06:09.067 回答
1

是的,这听起来很对。我认为一旦析构函数完成调用,内存将被转储回可分配池中,允许对其进行写入,从而可能导致后续析构函数调用出现问题(“this”指针无效)。

但是,如果析构函数在递归循环展开之前没有完成......理论上应该没问题。

有趣的问题:)

于 2010-06-17T16:00:32.233 回答
0

为什么有人想以这种方式递归调用析构函数?一旦调用了析构函数,它就应该销毁对象。如果您再次调用它,您将尝试启动已经部分破坏的对象的破坏,而您实际上仍然是同时实际破坏它的一部分。

所有的例子都有某种递减/递增的结束条件,本质上是在调用中倒计时,这暗示了嵌套类的某种失败的实现,其中包含与自身相同类型的成员。

对于这样一个嵌套的套娃类,递归地调用成员上的析构函数,即析构函数调用成员A上的析构函数,后者又调用自己的成员A上的析构函数,后者又调用析构函数……等等非常好,并且完全按照人们的预期工作。这是对析构函数的递归使用,但它并没有递归调用自身的析构函数,这很疯狂,几乎没有意义。

于 2017-08-05T23:57:16.243 回答