Wird das Zerstören einer großen Liste meinen Stapel überlaufen?

11

Betrachten Sie die folgende Implementierung einer einzeln verknüpften Liste:

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

Angenommen, ich verwende std::unique_ptr<node> headkeine Instanz mehr, die dann außerhalb des Gültigkeitsbereichs liegt und deren Destruktor aufgerufen wird.

Wird dies meinen Stapel für ausreichend große Listen sprengen? Ist es fair anzunehmen, dass der Compiler eine ziemlich komplizierte Optimierung durchführt (Inline unique_ptr-Destruktor in node's, dann Schwanzrekursion verwenden), die viel schwieriger wird, wenn ich Folgendes tue (da der dataDestruktor die verschleiern würde next, was es schwierig macht damit der Compiler die potenzielle Neuordnungs- und Tail-Call-Möglichkeit bemerkt):

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

Wenn es datairgendwie einen Zeiger nodedarauf gibt, kann es sogar unmöglich sein, den Schwanz zu rekursieren (obwohl wir uns natürlich bemühen sollten, solche Verstöße gegen die Kapselung zu vermeiden).

Wie soll man diese Liste dann im Allgemeinen sonst zerstören? Wir können die Liste nicht durchlaufen und den "aktuellen" Knoten löschen, da der gemeinsam genutzte Zeiger kein release! Der einzige Weg ist mit einem benutzerdefinierten Deleter, der für mich wirklich stinkt.

VF1
quelle
1
Für das, was es wert ist, war es auch ohne die im zweiten Fall erwähnte Verletzung der Kapselung gcc -O3nicht möglich, eine Schwanzrekursion zu optimieren (in einem komplizierten Beispiel).
VF1
1
Dort haben Sie Ihre Antwort: Es könnte Ihren Stapel sprengen, wenn der Compiler die Rekursion nicht wegoptimieren kann.
Bart van Ingen Schenau
@BartvanIngenSchenau Ich denke, das ist eine weitere Instanz dieses Problems . Es ist auch eine echte Schande, da ich die Sauberkeit intelligenter Zeiger mag.
VF1

Antworten:

6

Ja, dies wird irgendwann Ihren Stack sprengen, es sei denn, der Compiler wendet zufällig eine Tail-Call-Optimierung auf nodeden Destruktor und shared_ptr den Destruktor an. Letzteres hängt stark von der Standardbibliotheksimplementierung ab. Die STL von Microsoft wird dies beispielsweise niemals tun, da shared_ptrzuerst der Referenzzähler des Pointees (möglicherweise das Objekt zerstört) und dann der Referenzzähler des Steuerblocks (der schwache Referenzzähler) dekrementiert wird. Der innere Destruktor ist also kein Tail Call. Es ist auch ein virtueller Anruf , der es noch weniger wahrscheinlich macht, dass er optimiert wird.

Typische Listen umgehen dieses Problem, indem nicht ein Knoten den nächsten besitzt, sondern ein Container, der alle Knoten besitzt, und eine Schleife verwendet, um alles im Destruktor zu löschen.

Sebastian Redl
quelle
Ja, ich habe am Ende den "typischen" Algorithmus zum Löschen von Listen mit einem benutzerdefinierten Löscher für diese implementiert shared_ptr. Ich kann die Zeiger nicht vollständig entfernen, da ich die Thread-Sicherheit brauchte.
VF1
Ich wusste nicht, dass das Shared-Pointer "Counter" -Objekt auch einen virtuellen Destruktor haben würde. Ich habe immer angenommen, dass es nur ein POD ist, der die starken Refs + schwachen Refs + Deleter enthält ...
VF1
@ VF1 Sind Sie sicher, dass die Zeiger Ihnen die gewünschte Thread-Sicherheit bieten?
Sebastian Redl
Ja - das ist der springende Punkt bei den std::atomic_*Überlastungen für sie, nein?
VF1
Ja, aber das ist nichts, was man nicht erreichen std::atomic<node*>kann und billiger.
Sebastian Redl
5

Späte Antwort, aber da niemand sie zur Verfügung gestellt hat ... Ich bin auf dasselbe Problem gestoßen und habe es mithilfe eines benutzerdefinierten Destruktors gelöst:

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

Wenn Sie wirklich eine Liste haben , dh jedem Knoten ein Knoten vorausgeht und höchstens einen Follower hat und Sie listein Zeiger auf den ersten sind node, sollte das oben Genannte funktionieren.

Wenn Sie eine unscharfe Struktur haben (z. B. ein azyklisches Diagramm), können Sie Folgendes verwenden:

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

Die Idee ist, dass wenn Sie:

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

Der alte gemeinsame Zeiger nextwird zerstört (weil er use_countjetzt ist 0), und Sie zeigen auf Folgendes. Dies entspricht genau dem Standard-Destruktor, außer dass dies iterativ statt rekursiv erfolgt und somit ein Stapelüberlauf vermieden wird.

Holt
quelle
Interessante Idee. Ich bin mir nicht sicher, ob es die Anforderungen von OP an die Thread-Sicherheit erfüllt, aber sicherlich eine gute Möglichkeit, das Problem in anderer Hinsicht anzugehen.
Jules
Sofern Sie den Verschiebungsoperator nicht überladen haben, bin ich mir nicht sicher, wie dieser Ansatz tatsächlich etwas speichert - in einer realen Liste wird jede while-Bedingung höchstens einmal ausgewertet, next = std::move(next->next)wobei next->~node()rekursiv aufgerufen wird.
VF1
1
@ VF1 Dies funktioniert, weil next->nextes (durch den Verschiebungszuweisungsoperator) ungültig wird, bevor der Wert, auf den gezeigt nextwird, zerstört wird, wodurch die Rekursion "gestoppt" wird. Ich benutze diesen Code und diese Arbeit (getestet mit g++, clangund msvc), aber jetzt, wo Sie es sagen, ich bin nicht sicher , dass diese von der Norm definiert ist (die Tatsache , dass der verschobene Zeiger vor der Zerstörung des alten Objekts ungültig gemacht wird spitzer durch den Zielzeiger).
Holt
@ VF1 Update: Entspricht laut Standard operator=(std::shared_ptr&& r)äquivalent zu std::shared_ptr(std::move(r)).swap(*this). Nach dem Standard ist der Verschiebungskonstruktor von std::shared_ptr(std::shared_ptr&& r)make rleer und daher vor dem Aufruf von rleer ( r.get() == nullptr) swap. In meinem Fall ist dieses Mittel next->nextleer, bevor das alte Objekt, auf das gezeigt nextwird, zerstört wird (durch den swapAufruf).
Holt
1
@ VF1 Ihr Code ist nicht derselbe - Der Aufruf von fist aktiviert nextund nicht next->next. Da er next->nextnull ist, wird er sofort gestoppt .
Holt
1

Um ehrlich zu sein, bin ich mit dem Algorithmus zur Freigabe intelligenter Zeiger eines C ++ - Compilers nicht vertraut, aber ich kann mir einen einfachen, nicht rekursiven Algorithmus vorstellen, der dies tut. Bedenken Sie:

  • Sie haben eine Warteschlange mit intelligenten Zeigern, die auf die Freigabe warten.
  • Sie haben eine Funktion, die den ersten Zeiger nimmt und die Zuordnung aufhebt und dies wiederholt, bis die Warteschlange leer ist.
  • Wenn ein intelligenter Zeiger eine Freigabe benötigt, wird er in die Warteschlange gestellt und die obige Funktion wird aufgerufen.

Daher besteht keine Chance, dass der Stapel überläuft, und es ist viel einfacher, einen rekursiven Algorithmus zu optimieren.

Ich bin mir nicht sicher, ob dies in die Philosophie der "fast Null-Kosten-Smart-Pointer" passt.

Ich würde vermuten, dass das, was Sie beschrieben haben, keinen Stapelüberlauf verursachen würde, aber Sie könnten versuchen, ein cleveres Experiment zu erstellen, um mir das Gegenteil zu beweisen.

AKTUALISIEREN

Nun, das erweist sich als falsch, was ich zuvor geschrieben habe:

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

Dieses Programm baut ewig eine Kette von Knoten auf und dekonstruiert sie. Dies führt zu einem Stapelüberlauf.

Gábor Angyal
quelle
1
Ah, du willst den Continuation-Passing-Stil verwenden? Genau das beschreiben Sie. Ich würde jedoch eher intelligente Zeiger opfern, als eine andere Liste auf dem Heap zu erstellen, um eine alte freizugeben.
VF1
Ich habe mich geirrt. Ich habe meine Antwort entsprechend geändert.
Gábor Angyal