【问题标题】:Can a destructor be recursive?析构函数可以递归吗?
【发布时间】:2011-03-05 01:10:47
【问题描述】:

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

#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;
}

我的推理:虽然invoking a destructor twice is undefined behavior,根据 12.4/14,它的确切含义是这样的:

如果 为对象调用析构函数 谁的生命已经结束了

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

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

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

【问题讨论】:

  • 这真的很奇怪,你为什么要调用递归的析构函数?
  • 你为什么要这么做?
  • @Andrey 和 DeadMG:这主要是假设性的。随便阅读一下标准,就可以看出所有函数都可以递归调用,除了 main() (§5.2.2/9),但看起来没那么简单。
  • @Cubbi:“随意阅读标准……”套用 Lisa Simpson (snpp.com/episodes/2F13.html) 的话说,我知道这些话,但那句话对我来说毫无意义。
  • 这取决于你所说的“递归”。如果您看到对二叉树(包含指向 BinaryTree* leftSon 和 BinaryTree* rightSon 的指针的 BinaryTree 类)的遍历是递归的,那么它的析构函数也是递归的 :) 并且是的,它工作正常。

标签: c++ destructor standards-compliance


【解决方案1】:

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

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

有趣的问题:)

【讨论】:

    【解决方案2】:

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

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

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

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

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

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

    【讨论】:

    • @James:不相关的问题,但我在哪里可以下载/查看标准的文档?
    • D'oh,没有仔细检查 lifetime 的定义。但这听起来很奇怪,因为这意味着在对析构函数的正常调用期间,我正在访问生命周期已结束的对象的成员。但如果它这么说,那一定是真的。
    • 根据标准,它在技术上是未定义的,但在我看来,几乎任何合理的实现都允许它,因为在对象被释放之前,析构函数很容易实现为普通的成员函数调用。
    • @Jacob:如果你想要当前的官方标准,那么you have to buy it。如果你想要即将到来的 C++ 标准的最终委员会草案(它不是官方的或最终的或批准的,并且有很多很多的差异),你可以找到on the WG21 website;它是文件编号 N3092(靠近底部)。
    • @James:非常感谢!永远找不到好的链接。我想我应该在谷歌之前先检查一下 SO :)
    【解决方案3】:

    好的,我们知道行为没有定义。但是,让我们来看看真正发生的事情。我使用 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 
    

    在进行递归时,我们被困在地址01341586,内存实际上只在地址01341597 被释放。

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

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

    【讨论】:

    • 这仍然不是远程安全的:即使析构函数“只是”一个成员函数,每次它返回时它都会调用任何基类和成员变量的析构函数(这很容易测试) .
    • 测试某物的输出并不能表明任何代码的合法性。你得到的充其量是某个编译器在某段代码上的某种行为,但这与 C++ 作为一种语言无关。
    • 哦,不,在某些情况下,没有一个“解释”UB“是安全的”的答案。 UB不安全,期间。编译器不需要生成行为合理的代码。即使您现在看到了这种行为,编译器明天也不需要发出相同的机器代码。我最近问了这两个问题:stackoverflow.com/questions/3053904stackoverflow.com/questions/3059917,答案清楚地解释了你可以不遗余力地“证明”UB“在这种情况下是安全的”,但是你的“证明”无论如何都会不值钱什么都没有。
    • 来吧伙计们。这种实验精神和深入研究编译器实际所做的事情是值得钦佩和鼓励的,而不是批评。他显然不建议在现实生活中这样做,而且人们知道 C++ -> ASM 是一个好东西
    • @Andrey:问题是你还没有真正证明VS2008 是这样实现的。您所展示的只是*在您编译时,在您的特定硬件上,使用您的特定示例代码,它生成了此代码。不能保证相同的编译器会再次执行相同的操作。特别是,编译器可能会应用完全不相关但安全的优化,这些优化恰好会破坏依赖于未定义行为的代码。这是一个重要的警告。检查编译器在此处实际执行的操作很有趣,但它没有提供您可以依赖的信息。
    【解决方案4】:

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

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

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

    【讨论】:

    • 我看不到 dtor 主体本身无法提供哪些功能 - 无论是在标准上还是在实践中。关于对象的生命周期,您处于确切相同的位置。例如,您必须非常小心,不要从该分离的递归调用中调用虚函数,因为您会得到未定义的行为。
    • 析构函数不是一个普通的函数:你不应该直接调用析构函数:它只应该由管理对象释放的“框架代码”调用。这是否意味着析构函数可以或不能递归地调用自己,据我所知,语言规范中没有明确定义。编译器可能会以使递归调用不起作用的方式实现析构函数,例如它可能会过早地释放对象。所以我的观点是:为什么要冒险? (续)
    • (续)从析构函数中调用递归函数是合法的。因此,您可以通过让析构函数调用其他一些递归函数来轻松实现所需的效果。那么语言规范中的任何歧义都是无关紧要的。如果有人可以在语言规范中指出递归析构函数是安全且合法的,那么是的,我在这里的建议是不必要的。但由于我认为情况并非如此,不妨谨慎行事。即使在我不打算发生事故的日子里,我也会系上安全带。
    【解决方案5】:

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

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

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

    【讨论】:

      猜你喜欢
      • 2016-03-13
      • 2010-09-16
      • 2013-01-10
      • 2015-07-01
      • 2012-12-30
      • 2011-10-21
      • 2017-06-12
      • 2012-02-03
      • 2020-05-20
      相关资源
      最近更新 更多