Programming Languages Hacks

Importanti regole per linguaggi di programmazione rilevanti come Java, C, C++, C#…

  • Subscribe

  • Lettori

    I miei lettori abituali

  • Twitter

Memory Leak in C++ – Le Cinque (Semplici) Regole d’Oro Per Un Software “Leggero”

Posted by Ricibald on December 30th, 2006

I Memory Leak avvengono quando aree di memoria non più utilizzabili rimangono in memoria. Tali errori derivano dal mancato rispetto della seguente regola:

ogni oggetto allocato nello heap – con new T/new T[]/malloc – deve essere esplicitamente deallocato – con delete/delete[]/free – secondo le regole di corrispondenza indicate con il delimitatore ‘/’

Infatti, lo scope di una area heap coincide con quella dell’ultimo puntatore che ne contiene l’indirizzo, ma il suo lifetime coincide con l’intera durata del programma a meno dell’invocazione di delete/delete[]/free.
Se non è stato invocato il corrispondente delete/delete[]/free e l’ultimo puntatore disponibile va out of scope, l’area non è più accessibile ma continua a occupare memoria inutilmente generando l’errore di memory leak.

Si nota subito quindi che il problema riguarda esclusivamente l’area heap. Nello stack questo non è possibile poiché il lifetime di una variabile allocata nello stack coincide con il suo scope: quando la variabile è out of scope viene automaticamente deallocata.

Innanzitutto la regola base è quindi quella di rispettare le corrispondenze di allocazione/deallocazione: il new T deve essere seguito dal delete, il new T[] deve essere seguito dal delete[], la mallocdeve essere seguito da una free.

Spesso la causa più frequente di memory leak è omettere di deallocare le risorse quando non sono più necessarie. Immaginiamo ad esempio di avere la classe factory WindowFactory che crea oggetti con tipo base Window (supponiamo quindi che esistano sottoclassi di Window):

class WindowFactory {
    public:
        static Window* createWindow();
}

la funzione createWindow() restituisce un puntatore a un oggetto allocato dinamicamente di tipo base Window. Ci ricordiamo che restituire un puntatore in questo contesto è necessario per evitare il problema dello slicing. Quindi chi utilizza una Window deve:

  1. creare la Window con createWindow()
  2. usare la Window
  3. cancellare la Window con delete

Questo comporta che l’utilizzatore di Window è responsabile di deallocare la Window. Forse è un rischio che non vogliamo correre… La soluzione consiste nel creare una classe che funge da resource manager:

class WindowManager {
    private:
        Window* w;
    public:
        WindowManager() {
            w = WindowFactory::createWindow();
        }
        Window* getWindow() {
            return w;
        }
        ~WindowManager() {
            delete w;
        }
}
class TestWindow {
    void disegna() {
        WindowManager m;
        Window* w = m.getWindow();
        /* utilizza w... */
    } /* ora m è out of scope: w è deallocata */
}

La classe WindowManager ha la sola funzione di avvolgere Window per garantirne la deallocazione. L’utilizzo di WindowManager viene spesso chiamato Resource Acquisition Is Initialization (RAII) poiché è un idioma consolidato in C++ il fatto che nella stessa espressione un oggetto venga contemporaneamente dichiarato e inizializzato. Le classi RAII potrebbero essere viste come:

“So che i memory leak derivano dalla differenza concettuale tra heap e stack. Sarebbe bello se tutti gli oggetti che l’utilizzatore deve creare rispettassero le semplici regole di visibilità dello stack! In questo modo sarebbe impossibile generare memory leak! Ma come ottenere questo? Semplice: creo classi RAII

Stavolta infatti l’utilizzatore finale non crea nulla nello heap. La deallocazione segue esclusivamente le semplici regole dello stack: la deallocazione della Window è basata solo sullo scope di WindowManager.

Se generalizziamo questo approccio attraverso i template otteniamo gli smart pointer. I più utilizzati sono i reference-counting smart pointer (RCSP), che tengono traccia del numero di puntatori alla risorsa gestita e permettono di garantire una garbage collection per una particolare risorsa. In C++ gli RCSP coincidono con std::tr1::shared_ptr<T>.

Tornando al nostro problema, ricordiamo che l’obbiettivo sarebbe quello di creare una nuova Window senza delegare la responsabilità di cancellare la Window all’utilizzatore. In parte l’abbiamo ottenuto tramite il WindowManager, ma l’utilizzatore potrebbe comunque invocare WindowFactory::createWindow() a proprio piacimento e il problema rimarrebbe.

Sarebbe comodo che la stessa funzione WindowFactory::createWindow() restituisse un “qualcosa” che rispetti l’idioma RAII. E’ possibile ottenere questo comportamento avvolgendo la Window in uno smart pointer RCSP:

typedef boost::shared_ptr<Window> WindowPtr;
class WindowFactory {
    public:
        static WindowPtr createWindow();
}
class TestWindow {
    void disegna() {
        WindowPtr wp = WindowFactory::createWindow();
        wp->onClose(Window::EXIT);
        wp->view();
    } /* ora wp è out of scope: wp è deallocato */
}

Utilizzare classi RAII si rileva l’approccio migliore anche per prevenire i memory leak causati da eccezioni. Ad esempio:


class TestWindow {
    private:
        Window* w;
        Image* i;
    void disegna() {
        w = WindowFactory::createWindow();
        w->view();
        /* La creazione di Image potrebbe sollevare una eccezione */
        i = new Image("/home/ricibald/image.png");
        w->setImage(i);
        w->refresh();
        w->close();
        delete w;
    }
}

Se il costruttore di Image solleva una eccezione allora la Window puntata da w non verrà mai deallocata! Se invece utilizziamo classi RAII, il comportamento sarà corretto:

typedef boost::shared_ptr<Window> WindowPtr;
class TestWindow {
    private:
        WindowPtr w;
        Image* i;
    void disegna() {
        wp = WindowFactory::createWindow();
        wp->view();
        /* La creazione di Image potrebbe sollevare una eccezione */
        i = new Image("/home/ricibald/image.png");
        wp->setImage(i);
        wp->refresh();
        wp->close();
    }
}

Se viene sollevata una (qualsiasi) eccezione, wp diventa out of scope e la deallocazione avviene correttamente. Viene infatti invocato il distruttore di shared_ptr che a sua volta invocherà il delete di w.

Rispettare la regola RAII consente di prevenire la maggior parte degli errori, ma esistono anche memory leak causati da comportamenti indefiniti del compilatore, pena possibili memory leak “molto sottili“.

Esistono tre possibili comportamenti indefiniti del compilatore.

Il primo comportamento indefinito si può verificare con gli smart pointer. Si consideri infatti il seguente codice:

processaFinestra(boost::shared_ptr<Window>(new Window),priority());

il compilatore deve eseguire le tre seguenti cose

  • Invocare priority
  • Eseguire “new Window
  • Invocare il costruttore di boost::shared_ptr

Ma l’ordine delle operazioni è indefinito! Chiaramente il costruttore di shared_ptr dovrà essere invocato dopo “new Window“, ma priority() può essere invocato per primo, secondo o terzo! Consideriamo questa possibile sequenza di operazioni:

  1. Eseguire “new Window
  2. Invocare priority
  3. Invocare il costruttore di boost::shared_ptr

Ma se la funzione priority() sollevasse un’eccezione? In questo caso il puntatore restituito da “new Window” sarebbe perduto e l’oggetto Window referenziato non verrebbe deallocato: memory leak!! Come evitare questo comportamenti anomalo? Semplice: basta memorizzare gli smart pointer in istruzioni isolate. L’esempio precedente diventa quindi:

boost::shared_ptr<Window> wp(new Window);
processaFinestra(wp,priority());

Il secondo comportamento indefinito si può verificare nella deallocazione di classi polimorfiche. In classi base polimorfiche è necessario dichiarare il distruttore virtual. Se non viene rispettata questa regola, il programma funzionerà lo stesso, ma il comportamento della deallocazione è indefinito. Tipicamente le sottoclassi non sono distrutte, scatenando seri memory leak.

Il terzo comportamento indefinito si può verificare se i distruttori sollevano eccezioni. Se ciò dovesse avvenire, potrebbero attivarsi più eccezioni contemporaneamente, e in tal caso il risultato è indefinito. Ad esempio:

  1. il mio distruttore di una classe “Test” cancella una lista “finestre” che contiene N oggetti “Finestra
  2. il distruttore della classe “Finestra potrebbe generare un’eccezione in caso di errori in chiusura
  3. la deallocazione della lista “finestre” potrebbe generare più eccezioni contemporaneamente
  4. il risultato è indefinito: i rimanenti oggetti Finestra vengono deallocati? Ho memory leak?

è logico che non ci possiamo basare su risultati indefiniti! Quindi quali soluzioni adottare? Tre possibilità

  1. termina il programma in modo anomalo con std::abort()
  2. gestisci l’eccezione nel distruttore con try-catch, ma questo non consente di intervenire sull’errore
  3. fornisci una normale funzione close() che (1) deallochi oggetti Finestra e (2) sollevi l’eventuale eccezione. Tale funzione deve essere invocata esplicitamente dall’utilizzatore di Finestra.
    Il distruttore di Finestra, invece, verifica se tale funzione è stata invocata manualmente dall’utente (tramite una variabile booleana closed). Se la funzione close() non è stata ancora invocata, il distruttore garantisce comunque l’esecuzione di close(), con la differenza che stavolta l’ eventuale eccezione di close() deve essere gestita all’interno del costruttore, in modo analogo al precedente punto.
    Tutto questo si traduce in:

    class WindowException: public exception {
        virtual const char* what() const {
            return "Errore nella chiusura della finestra";
        }
    }
    class Finestra {
        private:
            Window w;
            boolean closed;
        public:
            ...
            void close() throw(WindowException) {
                w.close();
                if(!w.closed()) {
                    throw WindowException();
                }
                closed = true;
            }
            ~Finestra() {
                if( !closed ) {
                    try {
                        w.close();
                    } catch(WindowException& e) {
                        cerr << e.what << endl;
                        /* Se necessario esegui std::abort() */
                    }
                }
            }
    }
    

Concludendo, per prevenire (o addirittura impedire) memory leak bisogna rispettare le seguenti regole:

  • rispettare le corrispondenze: new T/new T[]/malloc corrisponde a delete/delete[]/free
  • restituire all’utilizzatore solo classi RAII tramite smart pointer o resource manager creati appositamente
  • creare smart pointer in istruzioni isolate
  • dicharare virtual i distruttori di classi polimorfiche
  • costruire distruttori privi di eccezioni. In caso di errori da gestire occorre fornire una funzione regolare che dia la possibilità di intervenire sull’eventuale eccezione (il comportamento del distruttore deve essere comunque garantito in caso di mancata invocazione di tale funzione regolare)

Questo lavoro è basato soprattutto sul libro di Scott Meyers “Effective C++ – Third Edition”.

Un’ altro riferimento molto interessante è l’articolo di George Belotsky.

Leave a Reply

You must be logged in to post a comment.