销毁大型列表会溢出我的堆栈吗?


12

考虑以下单链表实现:

struct node {
    std::unique_ptr<node> next;
    ComplicatedDestructorClass data;
}

现在,假设我停止使用某个std::unique_ptr<node> head超出范围的实例,从而导致其析构函数被调用。

这会否使我的堆栈大到无法容纳足够大的列表?假设编译器会做一个相当复杂的优化(将inline unique_ptr的析构函数插入node,然后使用尾部递归)是否公平,如果我执行以下操作,这会变得更加困难(因为data析构函数会混淆next's',使其变得困难)让编译器注意到潜在的重新排序和尾部调用机会):

struct node {
    std::shared_ptr<node> next;
    ComplicatedDestructorClass data;
}

如果data某种程度上有指向它的指针,node那么尾部递归甚至是不可能的(尽管我们当然应该努力避免这种封装破坏)。

通常,那么应该如何销毁该列表呢?我们无法遍历列表并删除“当前”节点,因为共享指针没有release!唯一的方法是使用自定义删除器,这对我来说真的很臭。


1
就其价值而言,即使没有第二种情况中提到的封装破坏,gcc -O3也无法优化尾部递归(在一个复杂的示例中)。
VF1

1
在那里,您可以找到答案:如果编译器无法优化递归,则可能会破坏堆栈。
Bart van Ingen Schenau 2015年

@BartvanIngenSchenau我想这是这个问题的另一个实例。因为我喜欢智能指针清洁度,所以这也真是太可惜了。
VF1

Answers:


7

是的,除非最终编译器恰好对node'的析构函数 shared_ptr '的析构函数进行尾部调用优化,否则这最终将使您的堆栈崩溃。后者非常依赖于标准库的实现。例如,Microsoft的STL将永远不会这样做,因为shared_ptr首先减小其指针的引用计数(可能会破坏对象),然后减小其控制块的引用计数(弱引用计数)。因此,内部析构函数不是尾部调用。这也是一个虚拟调用,这使得优化的可能性更低。

典型的列表通过不让一个节点拥有下一个节点来解决该问题,而是通过使一个容器拥有所有节点,并使用循环来删除析构函数中的所有内容来解决该问题。


是的,最后我为这些shared_ptrs 使用了自定义删除器实现了“典型”列表删除算法。由于我需要线程安全性,因此无法完全摆脱指针。
VF1

我也不知道共享指针“计数器”对象也将具有虚拟析构函数,我一直认为这只是一个持有强引用+弱引用+删除器的POD ...
VF1 2015年

@ VF1您确定指针可以为您提供所需的线程安全性吗?
塞巴斯蒂安·雷德尔

是的-这就是std::atomic_*他们超负荷的全部要点,不是吗?
VF1

是的,但这并不是您无法实现的std::atomic<node*>,而且更便宜。
塞巴斯蒂安·雷德尔

5

答案很晚,但由于没有人提供……我遇到了同样的问题,并使用自定义析构函数解决了这个问题:

virtual ~node () throw () {
    while (next) {
        next = std::move(next->next);
    }
}

如果您确实有一个list,即每个节点之前都有一个节点并且最多有一个跟随者,而您list是第一个跟随者的指针node,则上面的内容应该起作用。

如果您具有某种模糊结构(例如,非循环图),则可以使用以下内容:

virtual ~node () throw () {
    while (next && next.use_count() < 2) {
        next = std::move(next->next);
    }
}

这样做的想法是:

next = std::move(next->next);

旧的共享指针next被销毁了(因为它use_count现在是0),您可以指向以下内容。这与默认析构函数完全相同,不同之处在于它以迭代方式而不是递归地进行,从而避免了堆栈溢出。


有趣的主意。不确定它是否满足OP对线程安全性的要求,但是肯定是在其他方面解决该问题的好方法。
Jules

除非你重载的移动运营商,我这种做法实际上是不知道如何保存任何东西-在一个真正的列表,每个条件的同时将最多一次评估,以next = std::move(next->next)调用next->~node()递归。
VF1

1
@ VF1之所以起作用,next->next是因为在next销毁所指向的值之前,该无效(由移动分配运算符),从而“停止”了递归。我实际使用此代码,这项工作(经测试g++clangmsvc),但现在,你说,我确信我不是,这是由标准(事实上,移动的指针旧的对象尖销毁之前无效定义通过目标指针)。
Holt

@ VF1更新:根据标准,operator=(std::shared_ptr&& r)等效于std::shared_ptr(std::move(r)).swap(*this)。还是从标准来看,的move构造函数std::shared_ptr(std::shared_ptr&& r)使之为r空,因此在调用之前r为空(r.get() == nullptrswap。在我的情况下,这意味着next->next在指向的旧对象next被销毁(通过swap调用)之前为空。
Holt

1
@ VF1您的代码不同-对的调用fnext,不是next->next,并且由于next->next为null,因此会立即停止。
Holt

1

老实说,我对任何C ++编译器的智能指针释放算法都不熟悉,但是我可以想象有一个简单的,非递归算法可以做到这一点。考虑一下:

  • 您有一个等待释放的智能指针队列。
  • 您具有一个函数,该函数采用第一个指针并对其进行分配,然后重复该操作直到队列为空。
  • 如果智能指针需要释放,则将其推入队列并调用上述函数。

因此,堆栈没有机会溢出,优化递归算法要简单得多。

我不确定这是否适合“几乎零成本的智能指针”理念。

我想您所描述的不会导致堆栈溢出,但是您可以尝试构建一个聪明的实验来证明我错了。

更新

好吧,这证明了我之前写的错误:

#include <iostream>
#include <memory>

using namespace std;

class Node;

Node *last;
long i;

class Node
{
public:
   unique_ptr<Node> next;
   ~Node()
   {
     last->next.reset(new Node);
     last = last->next.get();
     cout << i++ << endl;
   }
};

void ignite()
{
    Node n;
    n.next.reset(new Node);
    last = n.next.get();
}

int main()
{
    i = 0;
    ignite();
    return 0;
}

该程序永久地构建和解构节点链。确实会导致堆栈溢出。


1
嗯,您是说要使用延续传递样式?实际上,这就是您所描述的。但是,我会牺牲智能指针,而不是在堆上建立另一个列表以仅仅释放旧的指针。
VF1

我错了。我相应地更改了答案。
的Gabor Angyal
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.