Dr. Carlo Pescio
Empty Base Optimization

Pubblicato su C++ Informer No. 2, Gennaio 1998

Come anticipato nel numero precedente di C++ Informer, in questa puntata completero' la discussione del trittico di ottimizzazioni ratificate dallo standard ANSI/ISO. Come ricorderete, le prime due ottimizzazioni (Return Value Optimization e Named Object Optimization) riguardavano un caso molto comune, ovvero la restituzione di un oggetto per valore. L'ottimizzazione che vedremo ora puo' sembrare a prima vista irrilevante; viceversa, si rivela utile in molte situazioni.

Classi vuote
Consideriamo una classe senza data member [non statici]. Per semplicita', consideriamo proprio una classe completamente vuota:

class Empty
  {
  } ;
Secondo lo standard (paragrafo 9.0.3), la dimensione di un oggetto di classe Empty non e' zero byte, come si potrebbe immaginare. E' un numero qualunque diverso da zero, che in gran parte delle implementazioni e' di un byte. La ragione e' presto detta: in C++, oggetti diversi devono avere indirizzi diversi. Se creiamo ad esempio un array di oggetti Empty:

Empty e[ 10 ] ;
ogni elemento di e deve avere un indirizzo diverso dagli altri. Se avessero dimensione zero, avremmo (ad es.) &(e[0]) == &(e[1]). Il comitato di standardizzazione aveva preso in considerazione una complicata proposta per mantenere la dimensione a zero e allo stesso tempo garantire indirizzi diversi, ma alla fine la regola "semplice" di cui sopra ha avuto il sopravvento. In fondo, le classi vuote non sembrano cosi' utili da giustificare una ulteriore complicazione del linguaggio.

Data member vuoti
Il succitato paragrafo 9.0.3 parla esplicitamente di "oggetti completi" vuoti e di "sotto-oggetti membro" vuoti, ed afferma che devono sempre avere dimensione non nulla. Quindi anche in una situazione come la seguente:

class EM
  {
  Empty e ;
  } ;
il sotto-oggetto membro "e" deve avere dimensione non-nulla. Tuttavia, ora il problema inizia a farsi sentire. Sulla maggior parte delle architetture, esiste un concetto di allineamento. Ad esempio, su un processore 486 un long int e' normalmente allineato su indirizzi che siano multipli di 32 bit, in modo da velocizzare l'accesso alla memoria. Su altri processori (es. Digital Alpha) accedere ad un long int non correttamente allineato causa una hardware trap, che viene faticosamente gestita dal sistema operativo, causando un rallentamento di diversi ordini di grandezza. Di conseguenza, i compilatori tendono ad allineare i dati all'interno delle struct (e delle classi) in modo da ottimizzare l'accesso ai singoli data member.
Cosa succede, allora, se abbiamo una classe come la seguente:
class C
  {
  Empty e ;
  long l ;
  } ;

In teoria, la dimensione di un oggetto di classe C e' sizeof( Empty ) + sizeof( long ). Su una architettura molto diffusa come quella Intel, tipicamente avremo sizeof( Empty ) == 1, sizeof( long ) == 4. Tuttavia se proviamo a compilare il codice di cui sopra, ci accorgiamo che sizeof( C ) == 8. Il compilatore "spreca" 3 byte di allineamento, pur di mantenere l allineato sui 32 bit.

Ma e' importante?
La prima reazione a quanto sopra e' normalmente una scrollata di spalle. In fondo, viene da pensare, di norma le nostre classi non sono vuote. Ed in effetti, una classe come Empty e' quantomeno rara. La definizione di classe vuota, pero', comprende molti piu' casi di quanto si tende ad associare al termine. Ad esempio, una classe con solo funzioni virtuali pure e' una classe vuota. Una classe con solo dati statici e funzioni statiche e' una classe vuota. Una classe con solo typedef all'interno e' una classe vuota. Ed ovviamente, una classe con una combinazione di quanto sopra e' una classe vuota. Un template di quanto sopra e' una classe vuota.
La libreria standard del C++, ad esempio, contiene parecchie classi template "vuote", il cui ruolo e' spesso quello di fornire dei typedef per altre classi. Un esempio di questi typedef e' presente nella Q&A della scorsa puntata di C++ Informer. Altri casi si possono trovare ad esempio in [1].
Non solo, una classe con solo dati statici (ma non necessariamente con solo funzioni statiche) e' molto utile per implementare una forma di controllo delle instanziazioni, detta classe monostato [2]. Chi ha seguito il mio intervento "Template Magic" al C++ Forum sa bene di cosa parlo.
Infine, un template con solo typedef e dati statici e' utile anche in situazioni un po' complicate come quella che vedremo in questa puntata di "No Limits C++" poco piu' avanti. Insomma, queste classi "vuote" saltano fuori con frequenza maggiore di quanto ci si aspetti, ed e' un peccato che facciano aumentare la dimensione degli oggetti che le contengono.

Empty Base Optimization
Lo standard contiene anche una nota a pie' pagina che spiega meglio il significato di "oggetto completo" e "sotto-oggetto membro". La nota dice chiaramente che il vincolo sulla dimensione diversa da zero non si applica ai sotto-oggetti dovuti all'ereditarieta'. Ovvero, nel caso seguente:

class E : private Empty
  {
  long l ;
  } ;
la classe E contiene un sotto-oggetto (padre) di classe Empty, che tuttavia non deve necessariamente avere dimensione diversa da zero. Perche' possiamo rilassare il vincolo? Perche' (apparentemente) il sotto-oggetto padre avra' comunque un indirizzo diverso dagli altri oggetti della stessa classe. Ricordiamo che il seguente codice e' illegale:

class Err : private Empty, private Empty
  {
  } ;
La situazione in realta' e' un po' piu' complicata, ma su questo tornero' alla fine. Rimane il fatto che un compilatore allineato con lo standard puo' ottimizzare la dimensione dei sotto-oggetti padre (nel caso siano di classe vuota) rendendola pari a zero. Di conseguenza, su un compilatore che implementa la Empty Base Optimization (es. Visual C++ 5), la dimensione di E sara' uguale alla dimensione di un long (ovvero 4 byte anziche' 8, riprendendo l'esempio precedente). Su compilatori piu' vecchi (ad es. un Borland 3) anche E avra' la dimensione "gonfiata" (8 byte) a causa di un sotto-oggetto di dimensione non nulla e dell'allineamento di l.

Conseguenze ed uso
Avrete notato l'uso dell'ereditarieta' privata nel listato precedente. Come ho discusso a fondo nel mio libro [3], l'ereditarieta' privata ha molto in comune con il contenimento. In generale, invece di scrivere:

class C
  {
  E e ;
  // ...
  } ;
dove E e' una classe vuota secondo la definizione precedente, e' sicuramente meglio scrivere:

class C : private E
  {
  // ...
  } ;
in quanto su compilatori con Empty Base Optimization otterremo oggetti piu' piccoli. E' importante notare che una delle principali distinzioni tra ereditarieta' protetta e contenimento, ovvero l'impossibilita' di avere molti sotto-oggetti dello stesso tipo tramite ereditarieta' privata (si veda sempre [3]), in questo caso non si applica. Infatti, essendo la classe vuota, avere uno o N sotto-oggetti e' totalmente equivalente. Lo scopo della classe e' solitamente di permettere l'accesso a elementi statici o typedef, e per fare questo basta un solo sotto-oggetto.
Naturalmente non e' strettamente necessario utilizzare l'ereditarieta' privata: possiamo, in determinate situazioni, utilizzare quella pubblica. Vi rimando comunque a [3] per un approfondimento sul tema.

Problemi inattesi
Recentemente sono emersi un po' di problemi con la Empty Base Optimization. In effetti, come avevo accennato, non e' poi cosi' immediato (anzi non e' proprio vero) che questa ottimizzazione sia cosi' sicura.
Consideriamo ad esempio il caso seguente:
class Base
  {
  } ;

class Derived : public Base
  {
  public :
    Base b ;
  } ;

Derived d ;
bool e = (&d == &d.b) ;

Il codice e' corretto perche' d e' di classe Derived, derivata da Base, e quindi possiamo confrontare il suo indirizzo con quello di d.b, che e' di classe Base. Secondo lo standard (paragrafo 5.10.2) si tratta di due sotto-oggetti diversi, che dovrebbero quindi avere indirizzi diversi. Tuttavia i compilatori a mia disposizione che implementano la Empty Base Optimization sbagliano, e come era ragionevole aspettarsi assegnano ad entrambi i sotto-oggetti lo stesso indirizzo. Altre anomalie sono riscontrabili con un uso mirato dell'ereditarieta' multipla fork-join.
Le conclusioni sono semplici: usata al punto giusto, la Empty Base Optimization impedisce che gli oggetti crescano di dimensione in modo ingiustificato. Non tutti i compilatori la supportano, o la supporteranno, e quelli che lo fanno hanno ancora qualche problema. Vale sicuramente la pena di adattare il proprio stile per consentire la EBO, ma almeno inizialmente verificate che non si creino problemi come quello su riportato. Se non avete percorsi multipli di ereditarieta' e/o aggregazione con radice nella stessa classe vuota, non dovreste correre nessun rischio anche con compilatori imperfetti come quelli attuali.

Bibliografia
[1] Andrew Koenig, "Inheritance and Abbreviations", Journal of Object Oriented Programming, Vol. 10 No. 5, September 1997.
[2] Steve Ball, John Crawford, "Monostate Classes: The Power of One", C++ Report, Vol. 9 No. 5, May 1997.
[3] Carlo Pescio, "C++ Manuale di Stile", Edizioni Infomedia, 1995.

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.