Distruggere un grande elenco trabocca il mio stack?


12

Considera la seguente implementazione dell'elenco collegato singolarmente:

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

Ora supponiamo che smetta di usare qualche std::unique_ptr<node> headistanza che poi esca dall'ambito, facendo chiamare il suo distruttore.

Questo farà esplodere il mio stack per liste sufficientemente grandi? È lecito ritenere che il compilatore eseguirà un'ottimizzazione piuttosto complicata (inline unique_ptr's destructor in node's, quindi usa la ricorsione della coda), che diventa molto più difficile se faccio quanto segue (dal momento che il datadistruttore potrebbe offuscare quello next, rendendolo difficile per il compilatore notare il potenziale riordino e opportunità di coda):

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

Se in dataqualche modo ha un puntatore al suo, nodeallora potrebbe anche essere impossibile per la ricorsione della coda (anche se ovviamente dovremmo cercare di evitare tali violazioni dell'incapsulamento).

In generale, come si può distruggere questa lista altrimenti, allora? Non possiamo attraversare l'elenco ed eliminare il nodo "corrente" perché il puntatore condiviso non ha un release! L'unico modo è con un deleter personalizzato, che è davvero maleodorante per me.


1
Per quello che vale, anche senza la violazione dell'incapsulamento menzionato nel secondo caso, gcc -O3non è stato in grado di ottimizzare una ricorsione della coda (in un esempio complicato).
VF1

1
Ecco la tua risposta: potrebbe far saltare il tuo stack, se il compilatore non è in grado di ottimizzare la ricorsione.
Bart van Ingen Schenau,

@BartvanIngenSchenau Immagino che sia un'altra istanza di questo problema . È anche un vero peccato, poiché mi piace la pulizia del puntatore intelligente.
VF1,

Risposte:


7

Sì, questo alla fine farà esplodere il tuo stack, a meno che il compilatore non applichi semplicemente un'ottimizzazione della coda al nodedistruttore e shared_ptr al distruttore. Quest'ultimo dipende in larga misura dall'implementazione della libreria standard. L'STL di Microsoft, ad esempio, non lo farà mai, perché shared_ptrprima decrementa il conteggio dei riferimenti della sua punta (possibilmente distruggendo l'oggetto) e poi diminuisce il conteggio dei riferimenti del blocco di controllo (il conteggio dei riferimenti deboli). Quindi il distruttore interno non è un richiamo alla coda. È anche una chiamata virtuale , il che rende ancora meno probabile che venga ottimizzata.

Gli elenchi tipici risolvono questo problema non avendo un nodo proprietario del successivo, ma avendo un contenitore che possiede tutti i nodi e utilizza un ciclo per eliminare tutto nel distruttore.


Sì, ho implementato l'algoritmo "tipico" di eliminazione dell'elenco con un deleter personalizzato per quelli shared_ptralla fine. Non riesco a liberarmi completamente dei puntatori poiché avevo bisogno della sicurezza del thread.
VF1,

Non sapevo che l'oggetto "counter" del puntatore condiviso avrebbe un distruttore virtuale, ho sempre pensato che fosse solo un POD che conteneva i ref forti + ref deboli + deleter ...
VF1

@ VF1 Sei sicuro che i puntatori ti offrano la sicurezza del thread che desideri?
Sebastian Redl,

Sì, è questo il punto dei std::atomic_*sovraccarichi per loro, no?
VF1

Sì, ma non è niente con cui non puoi ottenere std::atomic<node*>, ed è più economico.
Sebastian Redl,

5

Risposta tardiva ma dal momento che nessuno lo ha fornito ... Ho riscontrato lo stesso problema e risolto utilizzando un distruttore personalizzato:

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

Se hai davvero un elenco , cioè ogni nodo è preceduto da un nodo e ha al massimo un follower, e il tuo listè un puntatore al primo node, quanto sopra dovrebbe funzionare.

Se si dispone di una struttura sfocata (ad esempio un grafico aciclico), è possibile utilizzare quanto segue:

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

L'idea è che quando lo fai:

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

Il vecchio puntatore condiviso nextviene distrutto (perché use_countè ora 0) e si punta a quanto segue. Questo fa esattamente lo stesso del distruttore predefinito, tranne che lo fa in modo iterativo invece che ricorsivo e quindi evita lo stack overflow.


Idea interessante. Non sono sicuro che soddisfi i requisiti di OP per la sicurezza dei thread, ma sicuramente un buon modo per affrontare il problema sotto altri aspetti.
Jules,

A meno che tu non abbia sovraccaricato l'operatore di spostamento, non sono sicuro di come questo approccio salvi effettivamente qualcosa - in un vero elenco, ogni volta che la condizione verrà valutata al massimo una volta, con una next = std::move(next->next)chiamata next->~node()ricorsiva.
VF1

1
@ VF1 Funziona perché next->nextviene invalidato (dall'operatore di assegnazione degli spostamenti) prima che il valore indicato da nextvenga distrutto, quindi "arrestando" la ricorsione. In realtà uso questo codice e questo lavoro (testato con g++, clange msvc), ma ora che lo dici, non sono sicuro che sia definito dallo standard (il fatto che il puntatore spostato sia invalidato prima della distruzione del vecchio oggetto puntato dal puntatore target).
Holt

@ Aggiornamento VF1: secondo lo standard, operator=(std::shared_ptr&& r)equivale a std::shared_ptr(std::move(r)).swap(*this). Sempre dallo standard, il costruttore di mosse std::shared_ptr(std::shared_ptr&& r)rende rvuoto, quindi rè vuoto ( r.get() == nullptr) prima della chiamata a swap. Nel mio caso, questo significa che next->nextè vuoto prima che il vecchio oggetto puntato nextvenga distrutto (dalla swapchiamata).
Holt

1
@ VF1 Il tuo codice non è lo stesso - La chiamata a fè attiva next, no next->nexte poiché next->nextè nulla, si interrompe immediatamente.
Holt

1

Ad essere sincero, non ho familiarità con l'algoritmo di deallocazione del puntatore intelligente di qualsiasi compilatore C ++, ma posso immaginare un algoritmo semplice e non ricorsivo che lo faccia. Considera questo:

  • Hai una coda di puntatori intelligenti in attesa di deallocazione.
  • Si dispone di una funzione che accetta il primo puntatore e lo posiziona e lo ripete fino a quando la coda è vuota.
  • Se un puntatore intelligente necessita di deallocazione, viene inserito nella coda e viene chiamata la funzione sopra.

Quindi non ci sarebbe alcuna possibilità che lo stack traboccasse, ed è molto più semplice ottimizzare un algoritmo ricorsivo.

Non sono sicuro che ciò rientri nella filosofia "puntatori intelligenti a costo quasi zero".

Immagino che ciò che hai descritto non provocherebbe un overflow dello stack, ma potresti provare a costruire un esperimento intelligente per dimostrarmi che mi sbaglio.

AGGIORNARE

Bene, questo dimostra che cosa ho scritto in precedenza è sbagliato:

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

Questo programma costruisce e decostruisce eternamente una catena di nodi. Causa overflow dello stack.


1
Ah, intendi usare lo stile di passaggio di continuazione? In effetti, è quello che stai descrivendo. Tuttavia, sacrificherei prima i puntatori intelligenti piuttosto che creare un altro elenco sull'heap solo per deallocare uno vecchio.
VF1,

Mi sbagliavo. Ho modificato la mia risposta di conseguenza.
Gábor Angyal,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.