Dr. Carlo Pescio
Il vero auto_ptr

Pubblicato su C++ Informer No. 7, Aprile 1999

La libreria standard C++ si puo' dividere in quattro sotto-insiemi principali: le strutture dati ed algoritmi, che formano la parte comunemente chiamata "STL" (sigla non riconosciuta dallo standard), il sottoinsieme degli iostream, il supporto all'internazionalizzazione, ed infine un piccolo insieme di classi ausiliarie, come la stringa, il numero complesso, e l'auto_ptr.
Quest'ultima classe e' ancora relativamente poco utilizzata, ma non per questo poco importante: in effetti e' l'unico contributo della libreria standard alla scrittura di codice exception-safe.
Pur essendo una classe "piccolina", auto_ptr ha subito una serie di modifiche durante il processo di standardizzazione. Il risultato e' che l'auto_ptr distribuito con molti compilatori (es. il solito Visual C++) non e' allineato con lo standard. Il lato divertente (o deprimente, se siete pessimisti :-) e' che anche l'interfaccia specificata nello standard e' sottilmente sbagliata, e probabilmente verra' emessa una nota nei mesi a venire.
In quanto segue spieghero' brevemente a cosa serve auto_ptr e come si usa (rimandando comunque ad altri riferimenti bibliografici per ulteriori approfondimenti). Scendero' invece nei dettagli delle modifiche intervenute negli ultimi round di standardizzazione (dopo la pubblicazione del draft liberamente disponibile, che immagino molti di voi avranno scaricato). Modifiche di comportamento che hanno richiesto varianti implementative tutt'altro che semplici, anzi, quasi da No Limits. Non male per una classe di contorno :-).

Exception Safety
L'obiettivo iniziale di auto_ptr era di fornire un puntatore intelligente con overhead minimo (idealmente nullo) per supportare un particolare idioma di programmazione exception safe. Pensiamo infatti ad una semplice funzione che crei almeno un oggetto dinamicamente:

void f()
  {
  Pippo* p = new Pippo ;
  // ... usa p
  delete p ;
  }
Se nella parte indicata con "... usa p" viene sollevata un'eccezione non gestita in f(), inizia il cosiddetto "srotolamento dello stack", che tra l'altro fa uscire dallo scope di f(). Il problema e' che a quel punto nessuno rilascera' la memoria allocata per l'oggetto puntato da p, e neanche tutte le risorse allocate dal costruttore di Pippo: lo srotolamento dello stack, come la comune uscita da un blocco, comporta la distruzione degli oggetti automatici ma non di quelli allocati dinamicamente.
Ovviamente possiamo riempire tutto il nostro codice di blocchi try/catch e liberare le risorse allocate localmente:

void f()
  {
  Pippo* p = new Pippo ;
  try
    {
    // ... usa p
    }
  catch( ... )
    {
    delete p ;
    throw ;
    }
  delete p ;
  }
Questo e' un pessimo stile di gestione delle eccezioni, che somma la difficolta' di scrivere codice exception safe alla prolissita' della gestione tradizionale degli errori tramite return code. Chi scrive codice del genere tipicamente finisce per condannare la scelta di non avere un "finally" stile Java in C++.
Non discutero' a fondo lo stile corretto di gestione delle eccezioni in questo articolo. Si tratta di un argomento che richiede il suo tempo (in media un corso sull'uso corretto delle eccezioni richiede un paio di giornate) e ci distrarrebbe dall'obiettivo principale. Se siete interessati all'argomento, vi consiglio di scaricare le slide del mio intervento al C++ Forum '96 ("Uso corretto delle eccezioni"), liberamente disponibili tramite la mia pagina web, oppure di fare riferimento ad [1] per un buon trattamento dei principali problemi. In breve, l'idea di fondo di una buona gestione delle eccezioni e' di sfruttare lo stack unwinding, anziche' farsi sfruttare da esso.
Supponiamo di avere a disposizione un puntatore intelligente, che prenda possesso della risorsa puntata e la rilasci (con delete) nel suo distruttore; chiamiamo questo puntatore intelligente auto_ptr.
Allora potremmo scrivere f() come segue:
void f()
  {
  auto_ptr< Pippo > p( new Pippo ) ;
  // ... usa p

  // NO delete p ;
  }

Notiamo che non serve piu' il delete, perche' uscendo "regolarmente" dallo scope di f() tutti gli oggetti locali vengono distrutti. Il puntatore intelligente e' un oggetto (anche se si comporta come un puntatore), e nel suo distruttore chiama delete per la risorsa posseduta. Lo stesso avviene anche nel caso di uscita "irregolare" a causa dello stack unwinding, risolvendo facilmente almeno uno dei problemi legati alla gestione delle eccezioni.

Un auto_ptr minimale
Implementare un auto_ptr ridotto all'osso, con la semantica di possesso esclusivo di cui sopra, appare particolarmente semplice:

template< class T > class auto_ptr  
  {
  public :
    auto_ptr( T* p ) 
      { 
      ptr = p ; 
      }
    ~auto_ptr() 
      { 
      delete ptr ; 
      }
    T* operator ->() const 
      { 
      return ptr ; 
      }
    // ecc, operator *(), ...
  private :
    T* ptr ;  
  } ;
  
Notate che ho parlato chiaramente di possesso esclusivo: un auto_ptr possiede la risorsa puntata, che deve essere stata creata con new (visto che ~auto_ptr() la rilascia con delete) e deve essere puntata da un solo auto_ptr. L'auto_ptr non ha un reference count, che e' tipico dei puntatori intelligenti con semantica di possesso condiviso. Un puntatore con reference count non fa parte dello standard, sostanzialmente perche' non e' stato raggiunto un accordo tra chi proponeva un puntatore invasivo, e tra chi proponeva le N varianti non invasive.
L'idea di possesso esclusivo apre pero' la porta ad un problema molto spinoso, ovvero in quali situazioni si debba cedere il possesso ad altri, e come avvenga questa cessione.
La prima versione proposta (ormai dimenticata :-) di auto_ptr non prevedeva ne' un costruttore di copia ne' un operatore di assegnazione pubblici. L'idea era di restringere l'uso di auto_ptr alle sole variabili locali, come nel caso di f() visto sopra. D'altra parte, sembrava logico estendere l'utilizzo di auto_ptr anche ad altre situazioni: ad esempio, creare una risorsa in una funzione, utilizzarla, e poi restituirla al chiamante, che ne prendeva possesso. Dopodiche' comincia ad avere senso l'idea di avere un data member di tipo auto_ptr (anche questo molto utile per l'exception safety), e di conseguenza la possibilita' di scrivere member function che prendono un auto_ptr come parametro e che ne acquisiscono l'ownership (memorizzando il parametro in un data member).
Come tutto questo debba avvenire ha scatenato piu' di un putiferio all'interno del comitato di standardizzazione.

L'auto_ptr "intermedio"
Ad un certo punto il comitato di standardizzazione ha raggiunto un accordo sull'auto_ptr, che trovate nel secondo draft dello standard (quello pubblicamente disponibile). L'auto_ptr e' stato dotato di un costruttore di copia, di un operatore di assegnazione, e di un flag di ownership. Assegnando un auto_ptr, o copiandolo in un altro, si cedeva l'ownership ma non la navigabilita'. Ovvero, in un caso come il seguente:
auto_ptr< int > x( new int ) ;
auto_ptr< int > y = x ;

sia x che y puntavano allo stesso oggetto, ma solo y lo possedeva.

Personalmente ho sempre ritenuto questa scelta estremamente pericolosa, oltre che potenzialmente inefficiente. Si apriva la strada a dangling pointers con conseguente comportamento imprecisato. Ancora peggio, il flag di ownership era tipicamente dichiarato mutable, in modo che il costruttore di copia e l'operatore di assegnazione potessero prendere un riferimento a const come parametro.
Questo significava, tra l'altro, che chiamando una funzione dichiarata come:
void g( const auto_ptr< int >& p ) ;
non potevamo comunque essere sicuri che g() non ci rubasse l'ownership del parametro. Sicuramente una situazione fastidiosa.
L'alternativa logica (scrivere un costruttore di copia che prendesse un reference non const come parametro) avrebbe evitato anomalie come quella sopra, ma avrebbe impedito la scrittura di codice come il seguente:
auto_ptr< int > k() ; // k restituisce una risorsa attraverso un auto_ptr
// ...
auto_ptr< int > p( k() ) ; // errore di compilazione

Siccome il valore di ritorno di una funzione e' un temporaneo, ed un temporaneo puo' essere legato solo a reference const, usare un costruttore di copia che prende un reference non const come parametro impedisce, tra le altre cose, di restituire per valore un auto_ptr e legare il risultato ad una variabile.
A questo punto c'e' stata una protesta in fase di standardizzazione, l'auto_ptr ha rischiato di essere rimosso dallo standard, e poi il buon Greg Colvin (che e' sempre stato la mente dietro auto_ptr) ha proposto una variante che e' finita nel draft finale, e che risolve tutti i problemi di cui sopra anche se in modo un po' contorto.

Il vero auto_ptr
Quella che riporto sotto e' la versione "finale" di auto_ptr, documentata nello standard ANSI/ISO. Noterete la presenza di member template, indispensabili per poter scrivere codice come il seguente:

class Base
  {
  } ;

class Derived : public Base
  {
  } ;

auto_ptr< Derived > d( new Derived() ) ;
auto_ptr< Base > b( d ) ; // simula conversioni implicite derived->base

Vi rimando nuovamente ad [1] per un approfondimento delle tecniche usate per simulare la conversione implicita.

namespace std 
  {
  template<class X> class auto_ptr 
    {       
    template<class Y> struct auto_ptr_ref {};       
  
    public:       
      typedef X element_type;       
    
      // 20.4.5.1 construct/copy/destroy:       
      explicit auto_ptr(X* p=0) throw();       
      auto_ptr(auto_ptr&) throw();       
      template<class Y> auto_ptr(auto_ptr<Y>&) throw();       
      auto_ptr& operator=(auto_ptr&) throw();
      template<class Y> auto_ptr& operator=(auto_ptr<Y>&) throw();       
      ~auto_ptr() throw();       
     
      // 20.4.5.2 members:       
      X& operator*() const throw();       
      X* operator->() const throw();       
      X* get() const throw();       
      X* release() throw();       
      void reset(X* p=0) throw();       
     
      // 20.4.5.3 conversions:       
      auto_ptr(auto_ptr_ref<X>) throw();       
      template<class Y> operator auto_ptr_ref <Y>() throw();       
      template<class Y> operator auto_ptr<Y>() throw();       
    } ;       
  }
L'idea di fondo e' piuttosto semplice: l'auto_ptr e' tornato ad avere possesso esclusivo, senza flag di sorta. Assegnando un auto_ptr ad un altro, il sorgente perde il possesso (trasferito al destinatario) e diventa il puntatore nullo, evitando cosi' dangling pointers ed anche problemi di prestazioni dovuti al flag. Costruttore di copia ed operatore di assegnazione prendono un reference non const, cosicche' una funzione come la g() vista sopra da' la garanzia di non prendere il possesso della risorsa passata come parametro (perche' usiamo un reference a const).
Rimane pero' il problema sollevato sopra: come possiamo restituire per valore? Qui salta in ballo la classe auto_ptr_ref, che vedremo tra poco. Premetto solo una nota: auto_ptr_ref non e' una classe vuota come puo' sembrare da quanto sopra. E' abitudine non riportare, nello standard, dettagli di tipo implementativo: solo le interfacce vengono documentate. La classe auto_ptr_ref e' solo un dettaglio implementativo, senza funzioni, quindi e' documentata come vuota. Di fatto, dovra' contenere un puntatore ad un auto_ptr.

Sembrava facile...
Come anticipato, con il costruttore di copia e l'operatore di assegnazione dati sopra ci "perdiamo" qualche possibilita' di trasferimento di ownership, tra cui l'utilissimo caso di restituzione per valore di un auto_ptr. Vediamo allora i diversi casi di trasferimento, dai piu' semplici ai piu' complicati. In quanto segue, pensiamo che Base e Derived siano due classi, con Derived ovviamente derivato da Base.

Casi facili, senza problemi dovuti al const:
auto_ptr< Derived > p1( new Derived() ) ;
auto_ptr< Derived > p2( p1 ) ;  // chiama costruttore di copia 
p1 = p2 ; // chiama operatore di assegnazione
auto_ptr< Base > p3( p1 ) ; // chiama member template constructor 
p3 = p2 ;  // chiama member template assignment operator

Casi piu' difficili, con const:

Premessa: supponiamo di avere tre funzioni
auto_ptr< Derived > f1() ;
f2( auto_ptr< Derived > p ) ;
f3( auto_ptr< Base > p ) ;

Vediamo ora i diversi casi:
auto_ptr< Derived > p1( f1() ) ;

Il costruttore di copia non ha un match con il parametro, che e' un const auto_ptr< Derived >&. Tuttavia lo standard (8.5.0.14) prevede che in questi casi vengano presi in considerazione tutti i costruttori, non solo quello di copia! Si tratta di un punto subdolo che puo' facilmente sfuggire.
In questo caso non ci sono costruttori con un match perfetto, quindi il compilatore passa a considerare le conversioni implicite (nessuna utile) e quelle user-defined. L'unico percorso che trova e' il seguente:
auto_ptr< Derived > --> auto_ptr_ref< Derived > --> auto_ptr< Derived >
La prima --> e' una conversione user-defined, data dall'operatore di conversione relativo (member template). Notiamo che non si tratta di una member function const! Tutto funziona solo perche' lo standard dice chiaramente che un temporaneo e' legabile solo a reference const, ma se non lo leghiamo possiamo comunque chiamare tutte le sue member function, anche non const. La seconda --> e' a questo punto l'applicazione diretta di un costruttore, quello che prende auto_ptr_ref come parametro. Notiamo che anche l'auto_ptr_ref in questione e' un temporaneo, ma il costruttore di auto_ptr utilizzato prende un auto_ptr_ref per valore, quindi non vi sono problemi di const reference.
f2( f1() )
Nuovamente, il costruttore di copia non ha un match con il parametro, per le stesse ragioni di cui sopra. Secondo lo standard, la situazione e' esattamente analoga a quella sopra, e si applica lo stesso ragionamento.
auto_ptr< Base > p2( f1() ) ;
Notiamo che a differenza del primo caso qui stiamo costruendo un auto_ptr a classe base partendo da un auto_ptr temporaneo a classe derivata. Il percorso e' del tutto analogo a quello del primo esempio, solo che in questo caso le conversioni applicate sono:
auto_ptr< Derived > --> auto_ptr_ref< Base > --> auto_ptr< Base >

Notiamo infatti che l'operatore di conversione ad auto_ptr_ref e' un member template, ma il costruttore di auto_ptr che prende un auto_ptr_ref come parametro no. Solo grazie a questo il compilatore viene guidato senza ambiguita' nella scelta della conversione user-defined "giusta".
Nota per i perfezionisti: esiste un circolazione un documento attribuito a Colvin che spiega il funzionamento di auto_ptr_ref, in modo analogo a quanto sto facendo. Il documento sbaglia nel considerare il caso presente diverso dal successivo ed analogo invece al primo punto. In tutti i casi si passa per un auto_ptr_ref temporaneo, anche se per ragioni sottilmente diverse spiegate in sotto-paragrafi del punto 8.5.0.14 di cui sopra. Volendo scendere a quel livello di dettaglio, i primi due punti ed i secondi due (compreso quindi il presente) sono legali in virtu' di due sotto-paragrafi distinti. Fine nota.
f3( f1() ) ;
Questo caso e' del tutto analogo a quello precedente, con la costruzione di un auto_ptr_ref< Base > temporaneo a partire da un auto_ptr< Derived > temporaneo, e poi l'uso di auto_ptr_ref< Base > per creare un auto_ptr< Base >.

Notiamo che in tutti i casi che coinvolgono temporanei non si passa mai per il normale costruttore di copia. La classe auto_ptr_ref introduce un passo aggiuntivo che effettua un vero e proprio bypass del costruttore di copia, il cui prototipo e' inadatto a gestire le copie di auto_ptr temporanei.

Ultime note
Il ragionamento di cui sopra non vale se prendiamo in considerazione le assegnazioni. Cio' significa che non potremo scrivere:

auto_ptr< Derived > d( new Derived() ) ;
// ...
d = f1() ; // errore

Dovremo per forza scrivere codice come questo:
auto_ptr< Derived > d( new Derived() ) ;
// ...
auto_ptr< Derived > d1( f1() ) ;
d = d1 ;
Per altre indicazioni sull'uso "giusto e sbagliato" di auto_ptr vi rimando a [2]. Un'altra importante nota e' che auto_ptr non si puo' usare come parametro degli standard container. Scrivere ad es. vector< auto_ptr< Base > > e' un errore. La ragione e' piuttosto semplice: tutte le classi di STL possono assumere che le copie di un elemento siano equivalenti all'elemento originale. Cio' non e' vero per gli auto_ptr, dove fare una copia "ruba" l'ownership (pensate ad un contenitore che inserisca l'elemento, e poi ne faccia una copia in una variabile temporanea per qualche fine, es. di sorting; alla fine il temporaneo, che ha l'ownership, verrebbe distrutto, e nel contenitore resterebbe un auto_ptr a NULL). Infine, nel caso non ve ne foste accorti :-), tutta la parte sopra che descrive la conversione tra un auto_ptr< Derived > temporaneo ed un auto_ptr< Base > e' sbagliata (in fondo questo e' il numero di aprile :-). C'e' infatti un problemino piuttosto subdolo: scrivendo per esteso i namespace avremmo una sequenza del tipo: std::auto_ptr< Derived > --> std::auto_ptr< Derived >::auto_ptr_ref< Base > --> std::auto_ptr< Base > solo che il costruttore che dovrebbe fare la seconda ->> si aspetta come parametro uno std::auto_ptr< Base >::auto_ptr_ref< Base >. Il rimedio e' semplice, basta spostare auto_ptr_ref nel namespace std o usare una classe base, non template, per tutti gli auto_ptr e dichiarare li' auto_ptr_ref (e sperare che il compilatore implementi la empty base optimization di cui vi ho parlato nel numero 2 di C++ Informer). Vedremo cosa sceglieranno di fare i produttori di compilatori e librerie.

Conclusioni
Una piccola modifica alla semantica di valore, ed ecco che gestire i temporanei diventa un incubo. Va detto che la soluzione inserita nello standard e' complicata da capire, ma produce una classe ragionevolmente semplice da usare (che e' sempre un buon risultato). Va anche detto che e' una soluzione che si muove in punta di piedi sui confini del linguaggio, che "fortunatamente" e' abbastanza flessibile da consentirne il funzionamento. L'idea di una classe ausiliaria per gestire i temporanei e' molto furba, ed in futuro vi faro' vedere una applicazione di un concetto simile per gestire (o meglio: per far funzionare) le chiamate a funzioni virtuali dentro i costruttori. In No Limits, ovviamente.

Bibliografia
[1] Scott Meyers, "More Effective C++", Addison-Wesley, 1996.
[2] Kreft, Langer, "The auto_ptr Class Template", C++ Report, Nov-Dec 1998.

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.