La destruction d'une grande liste va-t-elle déborder ma pile?

12

Considérez l'implémentation de liste liée individuellement suivante:

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

Supposons maintenant que j'arrête d'utiliser une std::unique_ptr<node> headinstance qui sort alors du champ d'application, provoquant l'appel de son destructeur.

Est-ce que cela fera exploser ma pile de listes suffisamment grandes? Est-il juste de supposer que le compilateur fera une optimisation assez compliquée (le unique_ptrdestructeur en ligne dans nodele s, puis utilisera la récursivité de la queue), ce qui devient beaucoup plus difficile si je fais ce qui suit (puisque le datadestructeur masquerait nextle sien , le rendant difficile pour que le compilateur remarque la possibilité de réorganisation et d'appel de queue):

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

Si en dataquelque sorte a un pointeur vers son, nodeil peut même être impossible pour la récursivité de la queue (bien que nous devrions bien sûr nous efforcer d'éviter de telles violations de l'encapsulation).

En général, comment est-il censé détruire cette liste sinon? Nous ne pouvons pas parcourir la liste et supprimer le nœud "actuel" car le pointeur partagé n'en a pas release! Le seul moyen est avec un deleter personnalisé, qui me fait vraiment mal.

VF1
la source
1
Pour ce que ça vaut, même sans la rupture d'encapsulation mentionnée dans le deuxième cas, on gcc -O3n'a pas pu optimiser une récursivité de queue (dans un exemple compliqué).
VF1
1
Voilà votre réponse: cela pourrait faire exploser votre pile, si le compilateur ne peut pas optimiser la récursivité.
Bart van Ingen Schenau
@BartvanIngenSchenau Je suppose que c'est une autre instance de ce problème . C'est vraiment dommage aussi, car j'aime la propreté des pointeurs intelligents.
VF1

Réponses:

7

Oui, cela finira par faire exploser votre pile, à moins que le compilateur ne se trouve à appliquer une optimisation d'appel de queue au nodedestructeur et shared_ptr au destructeur de. Ce dernier est extrêmement dépendant de l'implémentation de bibliothèque standard. La STL de Microsoft, par exemple, ne le fera jamais, car shared_ptrelle diminue d'abord le nombre de références de sa pointe (peut-être en détruisant l'objet), puis diminue le nombre de références de son bloc de contrôle (le faible nombre de références). Le destructeur intérieur n'est donc pas un appel de queue. Il s'agit également d'un appel virtuel , ce qui rend encore moins probable son optimisation.

Les listes typiques contournent ce problème en n'ayant pas un nœud propre au suivant, mais en ayant un conteneur qui possède tous les nœuds et utilise une boucle pour supprimer tout dans le destructeur.

Sebastian Redl
la source
Ouais, j'ai implémenté l'algorithme de suppression de liste "typique" avec un suppresseur personnalisé pour ces shared_ptrs à la fin. Je ne peux pas me débarrasser complètement des pointeurs car j'avais besoin de la sécurité du fil.
VF1
Je ne savais pas non plus que l'objet "compteur" du pointeur partagé aurait un destructeur virtuel, j'ai toujours supposé qu'il ne s'agissait que d'un POD contenant les références fortes + les références faibles + le deleter ...
VF1
@ VF1 Êtes-vous sûr que les pointeurs vous offrent la sécurité de filetage que vous souhaitez?
Sebastian Redl
Oui - c'est tout l'intérêt des std::atomic_*surcharges pour eux, non?
VF1
Oui, mais ce n'est rien que vous ne pouvez pas réaliser std::atomic<node*>aussi, et moins cher.
Sebastian Redl
5

Réponse tardive mais puisque personne ne l'a fournie ... J'ai rencontré le même problème et l'ai résolu en utilisant un destructeur personnalisé:

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

Si vous avez vraiment une liste , c'est-à-dire que chaque nœud est précédé d'un nœud et a au plus un suiveur, et que vous êtes listun pointeur sur le premier node, ce qui précède devrait fonctionner.

Si vous avez une structure floue (par exemple un graphique acyclique), vous pouvez utiliser ce qui suit:

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

L'idée est que lorsque vous faites:

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

L'ancien pointeur partagé nextest détruit (car il l' use_countest maintenant 0) et vous pointez sur ce qui suit. Cela fait exactement la même chose que le destructeur par défaut, sauf qu'il le fait de manière itérative plutôt que récursive et évite ainsi le débordement de pile.

Holt
la source
Idée intéressante. Je ne suis pas sûr qu'il réponde aux exigences d'OP en matière de sécurité des threads, mais c'est certainement un bon moyen d'aborder le problème à d'autres égards.
Jules
Sauf si vous avez surchargé l'opérateur de déplacement, je ne sais pas comment cette approche enregistre réellement quoi que ce soit - dans une vraie liste, chaque condition while sera évaluée au plus une fois, avec next = std::move(next->next)appel next->~node()récursif.
VF1
1
@ VF1 Cela fonctionne car next->nextest invalidé (par l'opérateur d'affectation de déplacement) avant que la valeur pointée par ne nextsoit détruite, "arrêtant" ainsi la récursivité. J'utilise en fait ce code et ce travail (testé avec g++, clanget msvc), mais maintenant que vous le dites, je ne suis pas sûr que cela soit défini par la norme (le fait que le pointeur déplacé soit invalidé avant la destruction de l'ancien objet pointé par le pointeur cible).
Holt
@ Mise à jour VF1: selon la norme, operator=(std::shared_ptr&& r)est équivalent à std::shared_ptr(std::move(r)).swap(*this). Toujours à partir de la norme, le constructeur de déplacement de std::shared_ptr(std::shared_ptr&& r)fait rvide, rest donc vide ( r.get() == nullptr) avant l'appel à swap. Dans mon cas, ce moyen next->nextest vide avant que l'ancien objet pointé par ne nextsoit détruit (par l' swapappel).
Holt
1
@ VF1 Votre code n'est pas le même - L'appel à fest activé next, non next->next, et puisqu'il next->nextest nul, il s'arrête immédiatement.
Holt
1

Pour être honnête, je ne connais pas l'algorithme de désallocation de pointeur intelligent d'aucun compilateur C ++, mais je peux imaginer un algorithme simple et non récursif qui fait cela. Considère ceci:

  • Vous disposez d'une file d'attente de pointeurs intelligents en attente de désallocation.
  • Vous disposez d'une fonction qui prend le premier pointeur et le désalloue, et le répète jusqu'à ce que la file d'attente soit vide.
  • Si un pointeur intelligent a besoin d'une désallocation, il est poussé dans la file d'attente et la fonction ci-dessus est appelée.

Par conséquent, il n'y aurait aucune chance pour que la pile déborde, et c'est beaucoup plus simple que d'optimiser un algorithme récursif.

Je ne sais pas si cela correspond à la philosophie des «pointeurs intelligents à coût presque nul».

Je suppose que ce que vous avez décrit ne causerait pas de débordement de pile, mais vous pourriez essayer de construire une expérience intelligente pour me prouver le contraire.

MISE À JOUR

Eh bien, cela prouve mal ce que j'ai écrit précédemment:

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

Ce programme construit éternellement et déconstruit une chaîne de nœuds. Cela provoque un débordement de pile.

Gábor Angyal
la source
1
Ah, tu veux dire utiliser le style passant-continuation? Effectivement, c'est ce que vous décrivez. Cependant, je sacrifierais plus tôt des pointeurs intelligents que de constituer une autre liste sur le tas juste pour désallouer une ancienne.
VF1
J'avais tort. J'ai modifié ma réponse en conséquence.
Gábor Angyal