Dr. Carlo Pescio
Eccezioni, stringhe e sicurezza

Pubblicato su C++ Informer No. 10, Gennaio 2000

Usare correttamente le eccezioni non e' semplice. Se da un lato le eccezioni permettono di risolvere con eleganza e sicurezza un ampio numero di problemi, ne introducono comunque altri, meno tradizionali e quindi particolarmente pericolosi.
Ormai da diversi anni ho iniziato a scrivere codice esclusivamente secondo uno stile di "exception safety" piuttosto stretto, eppure occasionalmente emergono nuovi spunti che richiedono un riaggiustamento delle tecniche utilizzate.
In tempi recenti, infatti, mi e' capitato di soffermarmi su un potenziale problema che coinvolge eccezioni (incluse alcune eccezioni definite dallo standard) e stringhe.
In quanto segue vi esporro' il problema, una conseguente raccomandazione di codifica, e poi, nello stile No Limits della rubrica, una tecnica creativa per prevenire in modo piu' sistematico la comparsa del problema. La discussione del problema e' un po' tecnica; consideratela come un'invasione da parte della rubrica ANSI/ISO :-).

Eccezioni standard

Lo standard C++ prevede una classe base per tutte le eccezioni. Si tratta di un elemento molto importante in una corretta gestione delle eccezioni, che per essere realmente efficace deve spesso basarsi su catch polimorfe.
In particolare, la classe base std::exception segue lo schema che riporto (in parte) di seguito:

namespace std 
  { 
  class exception 
    { 
    public: 
      exception() throw(); 
      // ...
      virtual const char* what() const throw(); 
    };
  }
L'aspetto per noi interessante e' la funzione what(), che restituisce un messaggio descrittivo dell'eccezione occorsa, normalmente definito in classi derivate. Notiamo che il messaggio viene restituito come const char*.
Lo standard lascia ampia liberta' alle implementazioni sul contenuto del messaggio (si veda il punto 18.6 e successivi), e addirittura il contenuto del messaggio dopo una copia dell'oggetto-eccezione e' implementation-defined.
In realta', dubito che un compilatore che, a fronte di una copia dell'oggetto eccezione (che ricordiamo essere implicita in un throw), ponga ad es. a NULL il messaggio dell'oggetto copiato si possa reputare di buona qualita'.
La lettura comune dello standard e' pertanto che la stringa restituita debba avere static storage duration (ovvero vivere quanto il programma), o essere posseduta e gestita dall'oggetto eccezione, e che pertanto non debba essere rilasciata esplicitamente da nessuno, e comunque non da chi ha ottenuto l'oggetto exception tramite catch. Vi ricordo che secondo lo standard e' infatti lecito chiamare delete[] su un const char*: tuttavia, in questo caso il programmatore deve stare attento a non farlo, pena comportamento indefinito.

Siccome il problema delle chiamate in eccesso o in difetto a delete[] e' una delle principali argomentazioni per utilizzare un oggetto di classe stringa anziche' un const char*, ci si potrebbe chiedere *perche'* lo standard non richieda alla classe exception di restituire un oggetto string nella funzione what(). Questo risolverebbe infatti ogni possibile problema dovuto ad un uso improprio di delete[] sul risultato di what().

La ragione e' piuttosto semplice: il costruttore di copia di std::string non e' dichiarato come throw(), in quanto e' possibilissimo per tale costruttore allocare memoria e quindi essere a rischio di eccezione. Di conseguenza what() non potrebbe essere dichiarata come throw(). Lo standard riconosce il potenziale problema di string e preferisce esporre un poco elegante const char* per evitare altri guai.

Ma i guai sono sempre in agguato: proviamo ad andare un poco oltre nello standard, e scopriremo che alcune classi derivate da exception non sono cosi' caute nei confronti della memoria disponibile. Vediamo ad esempio la classe delle eccezioni sollevate dagli I/O stream:

namespace std 
  { 
  class ios_base::failure : public exception 
    { 
    public: 
    explicit failure(const string& msg); 
    virtual ~failure(); 
    virtual const char* what() const; 
    };
  } 
Notiamo che il costruttore di ios_base::failure prende un reference a string come parametro. Siccome il metodo what() (ridefinito) deve restituire un reference ad un const char* che punta ad una stringa uguale a msg.c_str(), e siccome tutte le eccezioni devono supportare correttamente il copy constructor, non vi sono molti gradi di liberta' per le implementazioni: in pratica, sono tutte costrette ad avere internamente un data member di tipo std::string, ad inizializzare tale data member con msg nel costruttore, ed a restituire il corrispondente c_str() nella funzione what() (operazione, peraltro, a rischio di eccezione).

Purtroppo questo approccio ha anche altri rischi: se il costruttore di copia di string alloca memoria, il costruttore di copia di ios_base::failure alloca inevitabilmente memoria. Se al momento del throw di un oggetto ios_base::failure non risulta possibile allocare memoria, il supporto run-time chiama terminate() senza tante formalita'. Questo comportamento e' sancito dallo standard al paragrafo 15.5.1p1, nota 134: "[...] if the object being thrown is of a class with a copy constructor, terminate() will be called if that copy constructor exits with an exception during a throw".
La situazione e' anche peggiore: nel caso di codice come il seguente:
throw std::ios_base::failure( "pippo" ) ;

entra in gioco non solo il costruttore di copia di string, ma anche il relativo costruttore con parametro const char*, e questo e' praticamente certo che allochera' memoria dinamica.

E allora?
Lo stesso discorso si applica anche alle nostre eccezioni: se vogliamo essere totalmente sicuri di poter sollevare una eccezione che verra' gestita da un ipotetico catch, e non tramite una brutale chiamata a terminate(), dobbiamo creare oggetti eccezione che non utilizzino memoria dinamica. Perlomeno, dovremo porci il problema ogni volta che la nostra eccezione puo' essere sollevata in situazioni di memoria ridotta o di potenziali failure dell'allocatore.
La restrizione sull'uso della memoria dinamica include anche ogni data member memorizzato all'interno del nostro oggetto eccezione: nessuno di essi deve corrispondere ad una classe che, nel suo costruttore o costruttore di copia, utilizzi memoria dinamica.

Sino a questo punto, siamo al livello della raccomandazione di codifica. Chi ha seguito il mio corso sull'uso corretto delle eccezioni dovrebbe avere da poco ricevuto alcune slide di aggiornamento che riprendono (molto brevemente) il problema su descritto e raccomandano di creare oggetti eccezione che non facciano, direttamente o indirettamente attraverso i data member, uso di memoria dinamica o di classi che possono sollevare eccezioni nel costruttore o nel costruttore di copia.

Consideriamo pero' un problema molto comune: la creazione di una classe derivata da exception, che contenga alcuni data member di tipo integral (es. un codice di errore), ma che vorremmo anche accompagnare ad una stringa descrittiva di quanto e' avvenuto.
Premesso che lo standard, in virtu' di quanto visto sopra, non impedisce a chi fa il catch di usare scorrettamente delete[] sul risultato di what(), e' almeno possibile costruire una classe eccezione che impedisca di passare al costruttore parametri sbagliati?
In altre parole, la nostra classe dovrebbe avere uno schema simile:
class SafeException : public exception 
    { 
    public: 
      explicit SafeException( const char* msg ) ; // ???
      // ...
      virtual const char* what() const throw() ;  
    };

Tuttavia dovrebbe impedire utilizzi scorretti come, ad esempio, i seguenti:
void f1()
  {
  char* p = new char[ 10 ] ; 
  // ...
  throw SafeException( p ) ; // vorrei errore compile-time
  }

void f2()
  {
  std::string s ; 
  // ...
  throw SafeException( s.c_str() ) ; // vorrei errore compile-time
  }

In altre parole, vorremmo che la nostra SafeException prendesse un parametro con garanzie particolari: la stringa passata dovrebbe avere la vita di un oggetto statico, in modo da permettere agli oggetti di classe SafeException di ricordarne semplicemente l'indirizzo (quindi senza allocazioni di sorta), ed allo stesso tempo evitare sgradite sorprese a chi fa un catch dell'eccezione e prova ad accedere al risultato di what().

Vincolare il tipo di stringa

Apparentemente il C++ non mette a disposizione alcun meccanismo per prevenire i problemi di cui sopra. Se dichiariamo il parametro come const char*, non possiamo impedire che ci venga passato un puntatore ad un temporaneo, o ad un oggetto allocato dinamicamente che potrebbe venire distrutto prima del catch, e cosi' via.

Come al solito, tuttavia, possiamo guardare meglio e scoprire alcuni aspetti meno noti del linguaggio che possono essere sfruttati per i nostri scopi. Non di rado, questi "aspetti meno noti" si nascondono tra le pieghe dei template, che sono stati l'elemento piu' intensamente potenziato durante la standardizzazione.

Leggendo lo standard, si scopre infatti che e' possibile usare un puntatore a carattere come parametro di un template, ma solo in condizioni particolari. Piu' precisamente (si veda il punto 14.3.2), l'uso seguente non e' corretto:

template< class T, char* p > class X 
  {
  //...
  } ;

X< int, "aaa" >  x1 ;
In quanto il literal "aaa", pur avendo static storage duration (come vorremmo noi!) ha anche internal linkage.
Tuttavia il seguente esempio di istanziazione e' corretto:
extern const char p[] = "bbb" ; 
X< int, p > x2 ;

Ammesso, tuttavia, che l'oggetto p abbia static storage duration. In pratica, p deve essere una costante globale, o per meglio dire a namespace scope.

Conseguenza: se utilizziamo la stringa come parametro di un template, ed il compilatore non protesta, siamo sicuri che la stringa in questione avra' static storage duration (ed external linkage, che in realta' ci interessa poco).
Richiedere questo impone delle restrizioni persino eccessive sulle stringhe che possiamo passare (a causa dell'external linkage), ma se temiamo che la nostra eccezione possa essere sollevata in situazioni di memoria ridotta, e' meglio essere troppo restrittivi che faciloni.

Una classe SafeException
A questo punto definire una classe SafeException diventa piuttosto semplice:
#include <exception>

template< const char* P > class SafeException : public std::exception
  {
  public :
    const char* what() const throw()
      {
      return( P ) ;
      }
  } ;

// Esempio di uso:

extern const char msg1[] = "message 1!" ;

void f()
  {
  // ...
  throw SafeException< msg1 >() ;
  // ...
  }

int main()
  {
  try
    {
    f() ;
    }
  catch( std::exception & s )
    {
    printf( "caught %s\n", s.what() ) ;
    }
  return 0 ;
  }


// Esempio che genera errore (come volevamo)

void f2()
  {
  // ...
  const char* p ;
  throw SafeException< p >() ;
  // ...
  }


// Esempio che genera errore (per eccesso di restrizione)

void f3()
  {
  // ...
  throw SafeException< "aaa" >() ;
  // ...
  }
Come vedete, dopo tante parole sono sufficienti poche righe di codice! In quelle poche righe vi sono comunque diverse implicazioni degne di nota: 1) Per semplicita', ho usato come classe base di SafeException la classe standard std::exception. In casi reali, sarebbe opportuno inserire una nostra classe intermedia. Notiamo che deve esistere una classe non-template come base di SafeException, altrimenti non saremmo in grado di eseguire un catch senza specificare il messaggio atteso. 2) Un oggetto di classe SafeException e' vuoto, a parte il puntatore alla virtual table. Questo permette al compilatore di utilizzare la Empty Base Optimization se usiamo SafeException come classe base (vedere C++ Informer #2, rubrica ANSI/ISO, per maggiori dettagli). E' interessante osservare come un oggetto di classe SafeException riesca a convogliare una informazione (il messaggio) pur avendo dimensione nulla. Il trucco sta, ovviamente, nell'aver cablato nel codice (istanza del template) il giusto indirizzo. Tutto questo a compile-time, naturalmente. 3) La classe SafeException e' molto restrittiva: puo' convogliare solo messaggi "fissi", definiti come costanti con external linkage. Per contro, offre la massima sicurezza riguardo la vita del messaggio stesso, e protegge da potenziali problemi legati alla scarsita' di memoria tra il throw ed il catch. Naturalmente, nulla impedisce di convogliare altre informazioni, come parte di una classe derivata da SafeException, sotto forma di codici scalari. 4) Un side-effect dell'implementazione e' che, se necessario o utile, possiamo confrontare il risultato di what() con i messaggi noti usando il semplice ==, anziche' l'usualmente necessario strcmp(). Vi lascio la spiegazione come mini-esercizio.

Conclusioni
Un "No Limits" molto semplice, che tuttavia riprende un concetto che ho piu' volte illustrato: il C++ e' un linguaggio molto potente, con costrutti anche di basso livello, che spesso richiedono molta cura nell'utilizzo da parte del programmatore. Tuttavia il C++ e' anche una "scatola di montaggio" per costruirsi un linguaggio di piu' alto livello, dove le garanzie sono maggiori e le intenzioni del programmatore piu' evidenti. A differenza di altri linguaggi, il C++ non forza sui programmatori un modello ritenuto "ideale" dai progettisti, ma lascia la liberta' (con onori ed oneri) di crearsi l'ambiente di programmazione piu' adatto. Capire questa seconda natura del C++ e' uno dei passi necessari per passare da un semplice utilizzo come linguaggio "decentemente OO, di livello piuttosto basso" ad un utilizzo come linguaggio a piu' livelli, dove costruiti gli strati piu' bassi (in accordo alle esigenze dei propri progetti) si puo' lavorare ad un livello di astrazione e di sicurezza superiore. Come sempre, cio' richiede un livello di studio e di esperienza maggiore, ma porta a risultati decisamente migliori.

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.