Dr. Carlo Pescio
Delete: cosa cambia?

Pubblicato su C++ Informer No. 3, Febbraio 1998

Il processo di standardizzazione del C++ ha tenuto in grande considerazione l'enorme quantita' di codice gia' scritto. In teoria, tutto il codice "corretto" doveva rimanere tale. In pratica, in diverse situazioni sono state praticamente indispensabili delle operazioni di clean-up del linguaggio. Come conseguenza, codice prima illegale diventa ora perfettamente lecito, ma in alcuni casi anche il codice prima lecito diventa improvvisamente illegale.
Scoprire tutte queste situazioni non e' facile, e lo standard non le evidenzia in modo chiaro (anche perche', in mancanza di un riferimento standard precedente, e' obiettivamente difficile dire da cosa ci si allontani: dal CFront? Dall'ARM? Dalle implementazioni di Microsoft e Borland?).
In questa puntata vedremo tutte le novita' riguardo l'operatore delete (e le delete expression), cominciando con un importante chiarimento terminologico.

Delete expression Vs operator delete
Penso che questa sia un'ottima occasione per eliminare una delle tante confusioni sulla terminologia corretta del C++. Una riga del tipo:
delete p ;
oppure
delete[] p ;

(altre varianti in seguito) viene chiamata delete expression. Il comportamento della delete expression e' il seguente:
- viene valutato, esattamente una volta, il valore di "p" (che puo' essere a sua volta un'espressione complessa).
- viene chiamato il distruttore sul valore risultante. Il distruttore viene chiamato in base al tipo dell'espressione, da cui l'importanza di definire distruttori virtuali [1]. Nel caso di array, viene chiamato il distruttore di ogni elemento, in ordine di indirizzo decrescente.
- viene liberata la memoria, chiamando *l'operatore delete* corrispondente.

Esistono quindi "due" delete: la delete expression, che chiama il distruttore e poi l'operator delete, e quest'ultimo, che invece rilascia solo la memoria. Questo e' totale analogia con la new expression e l'operator new, di cui probabilmente mi occupero' in futuro.

Prima modifica: const
Anche se il delete sembra un argomento dove e' difficile introdurre elementi di sofisticazione, durante la standardizzazione e' stato modificato in modo abbastanza pesante. La prima variazione di rilievo e' che ora l'argomento di una delete expression puo' essere const:

const int* p = new int ;
delete p ;
Questo codice e' attualmente illegale su molti compilatori: in effetti, una delle ragioni per passare un const T* come argomento delle funzioni e' sempre stata la garanzia che la memoria puntata non sarebbe stata rilasciata. Questa garanzia e' ora decaduta, e da questo punto di vista i puntatori "nudi" sono ora ancora meno sicuri di quanto fossero in precedenza. Il "razionale" di questa modifica e' di semplificare le situazioni in cui si vuole allocare dinamicamente un oggetto che non deve poi essere modificato.
Onestamente mi trovo in totale disaccordo ed avrei preferito il mantenimento della precedente restrizione; in ogni caso, il trend moderno e' di utilizzare classi smart pointer ovunque sia possibile, quindi il problema e' in qualche modo arginabile.

Seconda modifica: delete privato
L'operator delete puo' essere ridefinito, sia a livello globale che a livello della singola classe. Ad esempio:

class A
  {
  public :
    void* operator new( size_t s ) ;
    void operator delete( void* p ) ;
  } ;
Il caso piu' frequente in cui e' utile ridefinire gli operatori new e delete per una classe e' l'eliminazione della frammentazione della memoria in programmi ad operativita' continua, l'aggiunta di funzioni di debugging, oppure la necessita' di ottenere prestazioni migliori fornendo un allocatore ottimizzato. Se definiamo un operator delete nella nostra classe A, questo verra' utilizzato dalla delete expression quando l'argomento e' di tipo puntatore ad A (o classe derivata da A).
Cosa succede se dichiariamo l'operator delete come private? In molti compilatori attuali, il risultato e' che possiamo costruire dinamicamente oggetti di classe A, ma non possiamo distruggerli. Possiamo pero' creare oggetti automatici di classe A, perche' per gli oggetti automatici viene solo chiamato il distruttore, ma non l'operator delete. Questa tecnica e' stata talvolta utilizzata per creare classi "attive" (con thread dedicato), i cui oggetti una volta creati dinamicamente non potevano essere distrutti dall'esterno, ma solo dall'interno (al termine del thread).
Codice simile non funzionera' piu'. Secondo lo standard, se operator delete e' private, non possiamo neppure costruire dinamicamente gli oggetti (oltre ovviamente a non poterli distruggere).
La ragione e' questa volta da ricercare nel supporto alle eccezioni. Se in una new expression:

A* a = new A() ;
l'operator new ha successo (e quindi viene allocata memoria) ma il costruttore di A genera un'eccezione, l'intera espressione deve essere "annullata", e questo significa rilasciare la memoria allocata (tramite operator delete). Di conseguenza, operator delete deve essere accessibile per poter chiamare operator new. Questo ci porta direttamente ad una nuova modifica, l'introduzione del placement delete.

Terza modifica: placement delete
Come alcuni di voi sapranno, e' da tempo possibile ridefinire l'operator new in modo diverso da quanto visto sopra per la classe A. Un caso relativamente comune e' il cosiddetto placement new, che si utilizza normalmente per creare un oggetto ad un indirizzo prefissato:

class A
  {
  public :
    void* operator new( size_t s, void* p ) 
      {
      return( p ) ;
      }
  } ;

da utilizzare ad esempio come segue:
// sia p un puntatore ad una area di memoria
A* q = new( p ) A(); // crea un oggetto di classe A 
                     // all'indirizzo p.

Attenzione a non farvi ingannare: apparentemente l'operator new "non fa nulla". Ma l'espressione A* q = new( p ) A() fa molto di piu' che assegnare p a q. Innanzitutto, come chiarito all'inizio, chiama anche il costruttore di A sull'area di memoria puntata da p. Inoltre associa anche all'oggetto costruito la sua tavola delle funzioni virtuali.
Una nota doverosa prima di riprendere la discussione del delete: molti hanno tentato di utilizzare il placement new per creare oggetti in memoria condivisa (tra piu' processi). Questo normalmente non funziona, almeno non senza ulteriori passaggi. Il problema sta proprio nel puntatore alla tavola delle funzioni virtuali, che viene associato all'oggetto nel processo che lo crea, e quindi punta alla tavola solo nello spazio di indirizzamento di quel processo. Passando l'oggetto ad un altro processo, la chiamata di funzioni virtuali ha comportamento indefinito.
Torniamo ora al delete. Sino alla standardizzazione, non esisteva il corrispondente "placement delete". In effetti, il ragionamento comune era che il placement new creava l'oggetto ad un indirizzo prefissato (e quindi in memoria pre-allocata da altri) e quindi la distruzione non doveva avvenire tramite una delete expression. Dovevamo invece chiamare "manualmente" il solo distruttore, come in:

q->~A() ;
Ovviamente, questa assunzione e' stata presto ritenuta troppo limitativa. Il cosiddetto "placement new" di fatto puo' svolgere un compito qualunque, inclusa l'allocazione di memoria: non siamo ristretti al caso dell'esempio precedente. Ad esempio, potremmo passare un allocatore come parametro, ed allocare memoria per l'oggetto usando quello specifico allocatore:

class A
  {
  public :
    void* operator new( size_t s, Allocator& a ) 
      {
      void* p = a.Alloc( s ) ;
      return( p ) ;
      }
  } ;
 
Allocator al ;
A* a = new( al ) A() ;
Come possiamo rilasciare la memoria allocata nella costruzione di A? E ancora, come fa la libreria run-time a rilasciarla se il costruttore di A genera un'eccezione? La risposta, ovviamente, sta nell'introduzione di un "placement delete" corrispondente al placement new.
Nel caso precedente avremmo:
class A
  {
  public :
    void* operator new( size_t s, Allocator& a ) 
      {
      void* p = a.Alloc( s ) ;
      return( p ) ;
      }
    void operator delete( void* p, Allocator& a )
      {
      a.Release( p ) ;
      }
  } ;

A* a = new( al ) A() ;
// distruzione: vedi oltre

Con una importante nota. Se il costruttore di A genera un'eccezione dopo la chiamata ad un operator new con sintassi di placement, la libreria run-time chiama implicitamente il corrispondente operator delete di placement, se lo trova. Altrimenti assume che l'operator new di placement non allochi memoria, e non fa nulla.
Inoltre, non esiste una sintassi semplice per chiamare il placement delete manualmente, ovvero non possiamo scrivere:

delete( al ) a ; // errore!
Dobbiamo invece chiamare "a mano" il distruttore e poi l'operator delete di placement:

a->~A() ;
a->operator delete( al ) ;
La ragione, nuovamente, e' piuttosto oscura e va ricercata nel tentativo di congelare la sintassi del C++. Di fatto, e' conseguenza dello scarso interesse della maggior parte dei membri del comitato di standardizzazione per il placement delete.

Quarta modifica: no static delete (o new)
Molti compilatori accettano attualmente un operator delete (o new) globale definito come static. Il risultato e' che il significato di new/delete cambia per quella sola unita' di compilazione.
Lo standard ANSI/ISO proibisce invece di dichiarare delete o new static, nonche' di dichiararli in un namespace diverso dal namespace globale (a meno che si tratti di un class namespace). La giustificazione riportata in [2] (e questa volta a mio avviso piu' che condivisibile) riguarda l'inevitabile confusione che cio' genera, soprattutto se allochiamo un oggetto in una unita' di compilazione (o in un namespace) e lo deallochiamo in un'altra. Una sicura sorgente di bug, che fortunatamente ci viene evitata e per la quale e' previsto un messaggio di errore.

Quinta modifica: tipi incompleti
Il seguente codice ha ora comportamento indefinito:
// file1.cpp

class A ;

void f( A* a )
  {
  delete a ;
  } ;

// file2.cpp

class A
  {
  public :
    ~A()
      {
      // qualcosa di non banale, es.
      // delete di un data member
      // di tipo puntatore
      }
  } ;

Piu' precisamente (punto 5.3.5p5 dello standard) se l'oggetto distrutto ha tipo incompleto nel punto di distruzione, ed il tipo completo ha un distruttore non banale, il comportamento e' indefinito. Possiamo aspettarci un warning dai compilatori migliori (che lo emetteranno probabilmente anche quando il distruttore e' banale). Vi ricordo che comportamento indefinito vuol dire che il codice generato puo' fare qualunque cosa ed il compilatore che lo emette rimane standard.

Altre note sparse
Esiste un placement delete di libreria, detto nothrow delete, che viene usato per distruggere oggetti allocati con il nothrow new (che e' un particolare operator new globale, che in caso di fallimento restituisce NULL anziche' generare un'eccezione bad_alloc).
Parlando di delete, puo' anche essere interessante ricordare cosa succede in casi come questo:

A a ;
delete a ; // a e' un oggetto, non un puntatore
La risposta ovvia (e sbagliata) e' che il compilatore protesta sempre, perche' l'argomento di una delete expression deve essere un puntatore e non un oggetto. La risposta quasi giusta e' che se la classe A ha almeno un operatore di conversione a puntatore, l'oggetto verra' convertito ed il corrispondente puntatore distrutto (questa e' una ragione per evitare gli operatori di conversione a puntatore nelle classi smart pointer o nelle classi stringa). La risposta giusta, pero', e' che la classe A deve avere esattamente un operatore di conversione a puntatore, altrimenti l'espressione e' errata. Questo puo' suggerire una strada per continuare a fornire un operatore di conversione a puntatore per gli smart pointer, ma evitare cancellazioni errate involontarie:

template< class T > class SmartPointer
  {
  public :
    // ...
   operator T*() ;
  private :
   operator void*() ; // non implementato
  } ;

T< int > a ;
delete a ; // errore, conversione ambigua.

Conclusioni
Con buona probabilita', le modifiche apportate al delete in fase di standardizzazione non influenzeranno la correttezza del vostro codice: in fondo, sono stati introdotti molti cambiamenti ma quasi tutti ai margini, lasciando la sostanza invariata. Tuttavia, se avete usato le caratteristiche piu' avanzate del C++, e' possibile che il vostro codice sia improvvisamente diventato illegale. Sicuramente, se usate i puntatori "nudi" il vostro codice e' diventato improvvisamente meno sicuro, perche' anche i const T* possono ora essere distrutti senza cast. Una buona ragione per iniziare quanto prima ad utilizzare classi smart pointer, dall'auto_ptr standard ai puntatori con reference count.

Bibliografia
[1] Carlo Pescio, "C++ Manuale di Stile", Edizioni Infomedia, 1995.
[2] Josee Lajoie, "The C++ Memory Model", C++ Report, October 1996.

Biografia
Carlo Pescio (pescio@eptacom.net) svolge attività di consulenza, progettazione e formazione in ambito internazionale. Ha svolto la funzione di Software Architect in grandi progetti per importanti aziende europee e statunitensi. È autore di numerosi articoli su temi di ingegneria del software, programmazione C++ e tecnologia ad oggetti, apparsi sui principali periodici statunitensi, nonché dei testi "C++ Manuale di Stile" ed "UML Manuale di Stile". Laureato in Scienze dell'Informazione, è membro dell'ACM, dell'IEEE e dell'IEEE Technical Council on Software Engineering.