Dr. Carlo Pescio
Credevo fosse standard...

Pubblicato su C++ Informer No. 8, Giugno/Luglio 1999

Talvolta il confine tra "codice conforme", "codice con comportamento implementation-defined" e "codice con comportamento indefinito" e' piuttosto difficile da identificare. Come consulente, mi capita spesso di trovare codice che tutti pensano "assolutamente portabile" e che risulta invece avere comportamento indefinito, salvo che spesso "indefinito" significa "funziona con il mio compilatore con gli switch che uso di solito".
Per i lettori meno addentro alla terminologia ANSI/ISO, ricordo che "codice conforme" significa che ogni compilatore aderente allo standard e' tenuto ad accettare ed eseguire correttamente, in accordo alla specifica data nello standard, il codice stesso.
Codice con "comportamento implementation defined" e' un codice con sintassi corretta, il cui significato e' pero' definito dalle singole implementazioni dei compilatori (che sono tenuti a documentare ogni punto implementation-defined dello standard). Un esempio molto semplice: il valore di sizeof() applicato ad ogni tipo base, escluso [unsigned/signed]char, e' implementation-defined.
Codice con "comportamento indefinito" (a volte detto anche "imprecisato") e' un codice sintatticamente corretto ma semanticamente errato (vedremo alcuni esempi in quanto segue), per cui spesso non e' richiesta diagnostica ed a fronte del quale il compilatore puo' fare cio' che vuole: generare codice "sensato", generare codice che va in crash, generare codice che formatta il disco :-), non compilare affatto con messaggio di errore, ecc.
In questa puntata di ANSI/ISO passero' in rassegna alcuni casi frequenti (ed alcuni meno frequenti ma non meno gravi) di codice afflitto da problemi gravi di portabilita', non a causa di incompatibilita' dei compilatori, ma a causa di errori presenti nel codice. Da notare che alcuni problemi possono emergere semplicemente cambiando i parametri di compilazione o linkando con una diversa versione della libreria run-time.

Affermazione (FALSA) No 1.
"Se manca il distruttore virtuale, una delete polimorfa chiama solo il distruttore della classe base".
Sono in molti a pensare che il comportamento "standard" del seguente codice:
#include <iostream>

class A
  {
  public :
    A() 
      { 
      std::cout << "A" << std::endl ; 
      }
    ~A()       { 
      std::cout << "~A" << std::endl ; 
      }
  } ;

class B : public A
  {
  public :
    B() 
      { 
      std::cout << "B" << std::endl ; 
      }
    ~B()
      { 
      std::cout << "~B" << std::endl ; 
      }
  } ;
  
int main()
  {
  A* a = new B ;
  delete a ;
  return( 0 ) ;
  }

sia di chiamare il costruttore di B (come ovvio), quindi anche il costruttore di A (in modo implicito) ed infine il solo distruttore di A, non avendo A stessa un distruttore virtuale.
Viceversa, secondo lo standard ANSI/ISO il codice ha comportamento indefinito (5.3.5.3). Vi ricordo che "comportamento indefinito" significa che il compilatore, senza obbligo di diagnostica, puo' generare qualunque codice ritenga opportuno (incluso la formattazione del disco ;-) pur rispettando lo standard.
E' altresi' vero che molti compilatori generano, per l'esempio dato, codice che segue esattamente il folklore (chiama solo il distruttore di A). Perche' dunque lo standard definisce tale comportamento come imprecisato? Perche' il distruttore virtuale e' qualcosa di piu' di una semplice funzione virtuale.
Vediamo un caso concreto, una piccola modifica alla classe B data sopra dove inseriamo un operator new ed un operator delete custom, che in questo caso fanno semplicemente una trace e poi richiamano i new/delete di default:

class B : public A
  {
  public :
    void* operator new( size_t t ) 
      { 
      std::cout << "B::new" << std::endl ;
      return( ::operator new( t ) ) ; 
      }
    void operator delete( void* p )
      { 
      std::cout << "B::delete" << std::endl ;
      ::operator delete( p ) ; 
      }
    ~B()
      { 
      std::cout << "~B" << std::endl ; 
      }
  } ;
lo stesso main() visto prima, su molti compilatori ora chiamerebbe B::operator new(), poi B::B(), poi A::A() e poi A::~A(). NON chiamerebbe quindi B::operator delete. Se rendiamo A::~A() virtual (come deve essere in questi casi), tutto torna a funzionare come deve.
L'esempio chiarisce che il distruttore virtuale di norma "si porta dietro" altre informazioni utili, ad esempio quale operator delete chiamare. Siccome chiamare l'operator delete sbagliato ha comportamento indefinito, la mancanza del distruttore virtuale puo' facilmente implicare un comportamento indefinito, anche su compilatori dove codice analogo "chiama solo il distruttore della classe base".
Conseguenza: non pensate che la mancanza di un distruttore virtuale sia cosa da poco solo perche' il distruttore delle classi derivate non fa nulla di importante, o semplicemente e' vuoto. Il codice ha comunque comportamento indefinito ed e' quindi a rischio di portabilita'. Non solo, interventi di manutenzione come l'aggiunta dell'operator new possono cambiare radicalmente il comportamento del codice.

Affermazione (FALSA) No 2.
"Chiamare delete[] o delete e' indifferente su array di tipi base".
Anche in questo caso, sono ancora in molti a pensare che il codice seguente:
void main()
  {
  char* a = new char[ 100 ] ;
  delete a ;
  return( 0 ) ;
  }

sia equivalente al seguente:
void main()
  {
  char* a = new char[ 100 ] ;
  delete[] a ;
  return( 0 ) ;
  }

La motivazione abitualmente portata e' che "dimenticando le [] si chiama il distruttore solo per il primo elemento, ma siccome il distruttore dei tipi base non fa niente, il risultato e' lo stesso".
Nuovamente, il codice ha invece comportamento indefinito secondo lo standard (5.3.5.3). Di nuovo, viene da chiedersi il perche'; la ragione e' tuttavia piuttosto semplice: il compilatore ha il diritto di mantenere pool di memoria diversi per tipi diversi, o per array ed oggetti singoli. Se mantiene un pool per gli oggetti ed uno per gli array, usera' ovviamente il secondo quando si usa operator new[]() per allocare un array (anche di tipi base); in questo caso, delete tentera' di rilasciare nel pool degli oggetti, e delete[] nel pool degli array. Rilasciare memoria con l'operatore sbagliato significa quindi rilasciare un blocco al pool sbagliato, e questo puo' tranquillamente avere effetti imprecisati: per la cronaca, diversi allocatori commerciali (piu' veloci di quelli built-in dei compilatori) usano questa strategia e si inchiodano (o segnalano una asserzione violata in versione debug) nel caso di codice errato come il precedente.
A nulla vale il discorso del distruttore virtuale in queste occasioni: usare la delete sbagliata sui tipi base ha comportamento indefinito, cosi' come sui tipi user-defined, indipendentemente dalla presenza di un distruttore virtuale.

Affermazione (FALSA) No. 3
"Posso sempre convertire un puntatore in un intero".
Questa opinione e' cosi' radicata che anche persone esperte hanno affermato di poter comunque confrontare due puntatori, semplicemente convertendoli ad un intero (vi ricordo infatti come punto 3/a che secondo lo standard possiamo confrontare con <, <=, >, >= solo puntatori ad oggetti all'interno dello stesso array, con l'eccezione di un elemento oltre la fine dell'array).
In realta' lo standard non impone che vi sia alcun tipo integral in grado di rappresentare tutti i bit di un puntatore. Ad esempio, e' lecito avere una architettura con puntatori a 64 bit e long a 32 bit. Ogni conversione puntatore -> long va quindi vista come un potenziale problema di portabilita'.

Affermazione (FALSA) No. 4
"Posso usare la macro offsetof per calcolare la distanza in byte tra due data member di una classe qualunque".
Questa affermazione e' piu' rara, ma non meno critica in quanto spesso proviene proprio da persone normalmente classificate come "esperte". Ad esempio Don Box nel suo (peraltro ottimo) testo "Essential COM" usa offsetof su una classe con metodi virtuali, ed afferma che l'uso e' portabile (pag. 174).
Purtroppo si sbaglia (e non solo qui per quanto riguarda il C++ :-), perche' lo standard garantisce l'uso di offsetof _solo_ sui POD (Plain Old Data). Spiegare nei dettagli cosa e' un POD e cosa non lo e' richiede un po' di spazio, ma molto brevemente, potete pensare ad un POD come ad una delle struct del C. Se aggiungete metodi virtuali, o un distruttore, o un costruttore di copia, o un data member di tipo reference, ecc, allora la classe (o struct) non e' piu' un POD, ed offsetof ha comportamento indefinito.

Affermazione (FALSA) No. 5
"Posso usare in modo portabile una member function statica come se fosse una funzione C (ad esempio come callback)".
Una funzione statica non ha un parametro "this" nascosto, e sembra quindi la candidata ideale per incapsulare una funzione callback, o per darle accesso ai dati privati di una classe. Ad esempio, molte librerie di classi che interagiscono con le API di Windows usano funzioni statiche laddove le API si aspettano funzioni con C linkage. A dire il vero anche io le uso spesso in modo analogo nelle mie classi.
E' importante tuttavia sapere che questo utilizzo non e' garantito dallo standard: una static member function ha C++ linkage, e non e' detto che questo sia compatibile con il C linkage, ad esempio a livello di passaggio di parametri. Variare un parametro di compilazione puo' creare problemi, e cosa succeda in caso di eccezione sollevata dalla funzione statica quando lo stack unwinding passa attraverso lo "strato C" chiamante e' al piu' implementation-defined.

Affermazione (FALSA) No. 6
"Posso creare un reference nullo al tipo T come segue:
T& t = *((T*)NULL) ;
perche' il puntatore non viene realmente dereferenziato".

Una variante della stessa affermazione e' che "&*0 vale 0, perche' & e * si annullano a vicenda". Entrambe le affermazioni non hanno alcun fondamento: il codice ha comportamento indefinito. In generale, il compilatore ha tutto il diritto di generare codice dove & e * non si annullano, ovvero dove il puntatore viene effettivamente dereferenziato.
Una leggera variante della stessa affermazione e' la seguente:

Affermazione (FALSA) No. 7
"Posso chiamare una member function non virtuale anche usando il puntatore nullo come this. Posso verificare questo caso con this != NULL dentro la member function".
Di nuovo, chi fa questa affermazione ha in mente un modello computazionale del C++, rispettato da diversi compilatori, e pensa erroneamente che sia il "modello standard" del C++. Nel modello computazionale comune, se la member function chiamata non e' virtuale non vi e' alcun bisogno di dereferenziare il puntatore su cui viene chiamata, che viene semplicemente passato come parametro aggiuntivo ("this"). Ma non vi e' alcuna garanzia nello standard circa il modello computazionale da adottare. Un compilatore ha il diritto di trattare ogni member function come virtuale, ad esempio per garantire una compatibilita' binaria tra versioni diverse della stessa classe. Ha anche il diritto di dereferenziare this comunque, ad esempio per emettere una diagnostica run-time se e' nullo. In realta', ha il diritto di fare cio' che vuole con codice che chiama una member function con this == NULL, perche' nuovamente siamo di fronte a codice con comportamento indefinito.

E poi?
La lista andrebbe avanti ancora per molto... dal fatto che un carattere non e' necessariamente ad 8 bit o che la rappresentazione dei numeri interi non e' necessariamente in complemento a due, sino a cose piu' subdole come questa:

struct S
  {
  char x ;
  int y ;
  } ;

int main()
  {
  S s1, s2 ;
  s1.x = s2.x = 'x' ;
  s1.y = s2.y = 42 ;
  if( memcmp( &s1, &s2, sizeof( S ) ) )
    std::cout << "diverse" ;
  else
    std::cout << "uguali" ;
  return( 0 ) ;
  }
Il programma puo' tranquillamente stampare "diverse" su un compilatore conforme allo standard. Se non siete convinti, pensateci su e magari provate con il vostro solito compilatore.
La morale della storia e' semplice: il C++ non fa alcun tentativo per proteggervi dalla confusione tra il modello computazionale che avete in mente e quanto realmente specificato dallo standard, che invece lascia molta liberta' agli implementatori dei compilatori. Se volete (o dovete!) scrivere codice realmente portabile, ogni riferimento ad un particolare modello computazionale e' a rischio: nel dubbio, consultate lo standard.

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.