Dr. Carlo Pescio
Impedire lo Slicing

Pubblicato su C++ Informer No. 4, Aprile 1998

I programmatori che iniziano ad usare il C++, magari provenendo da un altro linguaggio ad oggetti come Smalltalk o in tempi recenti Java, spesso dimenticano che alcune operazioni possono causare il cosiddetto slicing degli oggetti.
Ad esempio, se passiamo un oggetto di classe derivata per valore ad una funzione che ha come tipo del parametro la classe base, l'oggetto passato verra' "affettato" (sliced) e solo la porzione di classe base verra' effettivamente passata. La conseguenza diretta dello slicing e' che il polimorfismo ad oggetti, in C++, funziona solo sui puntatori e sui reference ma non sugli oggetti.
Salvo casi rari, lo slicing corrisponde quasi sempre ad un errore di programmazione. D'altra parte, essendo una operazione perfettamente ammissibile, il compilatore non emettera' nessun messaggio, anche se personalmente non mi dispiacerebbe ricevere almeno un warning.
Quando una operazione e' quasi sempre errata, tuttavia, non e' molto bello che avvenga in modo nascosto, implicito, e senza neanche un warning. Di fronte ad un problema come lo slicing abbiamo almeno tre possibilita':
1) Unirci al gruppo dei denigratori del C++ e passare a Java.
2) Unirci al gruppo di chi pensa di poter scrivere un C++ migliore e perdere i prossimi due anni a scrivere un compilatore che nessuno usera' mai.
3) Rimboccarci le maniche e trovare un modo per cambiare il comportamento del linguaggio, restando dentro il linguaggio. Questo e', ovviamente, un lavoro da No Limits.

Il problema
Prima di proseguire con le varie soluzioni (o presunte tali), e' sempre meglio fermarsi e definire al meglio il problema. In particolare, dobbiamo capire in quali contesti puo' avvenire uno slicing, o meglio quando puo' avvenire in modo implicito, senza che il programmatore lo richieda esplicitamente.
Date due classi come le seguenti:
class Base
  {
  } ;

class Derived : public Base
  {
  } ;

Lo slicing di oggetti Derived in oggetti Base puo' avvenire in quattro situazioni diverse:

1) Costruttore di copia, sintassi "di assignment"
Derived d ;
Base b = d ;

2) Costruttore di copia, sintassi "a funzione"
Derived d ;
Base b( d ) ;

3) Assegnazione
Derived d ;
Base b ;
b = d ;

4) Chiamata di funzione con passaggio per valore
void f( Base b )
  {
  }

void g()
  {
  Derived d ;
  f( d ) ;
  }

Il nostro obiettivo e' impedire che questo avvenga in modo implicito, lasciando pero' la possibilita' al programmatore di forzare lo slicing ogni volta che lo ritiene necessario.

False soluzioni
Di fronte ad un problema un po' inusuale come quello che stiamo affrontando, e' normale "esplorare" alcune soluzioni che sembrano promettenti ma che si rivelano sbagliate. Anziche' presentarvi solo la soluzione "finale", ho ritenuto interessante discutere anche qualche alternativa sbagliata o incompleta. In fondo, si impara molto anche dagli errori.
L'idea piu' semplice (e peraltro l'unica potenzialmente percorribile con in compilatori di qualche anno fa) e' di passare dall'ereditarieta' pubblica a quella privata. In effetti, in questo modo risolviamo tutti i punti sopra esposti. Pero' abbiamo un po' esagerato, impedendo anche a codice perfettamente corretto come il seguente di funzionare:

Derived d ;
Base* b = &d ;
Abbiamo quindi eliminato lo slicing, ma ci siamo persi per strada anche la possibilita' di usare il polimorfismo ad oggetti.
Una alternativa promettente e' l'uso di explicit, come abbiamo visto nello spazio ANSI/ISO C++ in questo stesso numero. Possiamo tentare di eliminare lo slicing implicito dichiarando il costruttore di copia come explicit (vi ricordo che anche un costruttore di copia e' un converting constructor):

class Base
  {
  public :
    explicit Base( const Base& b ) ; 
  } ;

class Derived : public Base
  {
  } ;
A questo punto abbiamo sicuramente risolto il punto (4), perche' al momento della chiamata f( d ), il costruttore explicit dovrebbe essere chiamato implicitamente, operazione ora illegale.
Su alcuni compilatori, ho notato che il codice di cui sopra risolve anche il punto (1), anche se onestamente lo definirei un errore del compilatore (che peraltro lascia passare senza problemi la versione (2), che in questo caso e' del tutto equivalente).
Sicuramente, l'uso di explicit non riesce a risolvere il problema dell'assegnazione (3), e rimane quindi una soluzione parziale.

La soluzione
Dimentichiamo per un attimo tutte le buone regole di programmazione ad oggetti. In particolare, dimentichiamoci dell'importanza di evitare dipendenze circolari tra le classi, in questo caso tra classe base e classe derivata. Allora potremmo dichiarare, nella classe base, un costruttore ed un operatore di assegnazione privati che prendono come parametri un reference alla classe derivata. Questo risolverebbe tutti i punti (1)...(4), ma ci lascerebbe con un problema ancora piu' grande (colpa nostra, abbiamo volutamente ignorato una regola fondamentale). Se ora deriviamo una nuova classe da Base, dobbiamo anche modificare Base stessa ed aggiungere un nuovo costruttore ed un nuovo operatore di assegnazione privati. Addio riuso ed OOP.
In realta' non e' cosi': possiamo facilmente "nobilitare" la soluzione di cui sopra eliminando la dipendenza circolare grazie all'uso dei template (questo e' peraltro un esempio di applicazione delle regole e delle tecniche di trasformazione del Systematic OOD di cui vado spesso parlando). Un uso certamente un po' anomalo, che ha l'obiettivo di rimanere nascosto e di impedire uno dei fenomeni che tanto piacciono ad alcuni seguaci dei template (le conversioni implicite).
Vediamo come si traduce in codice:
class Base
  {
  private :
    template< class T > Base( const T& t ) ; 
    template< class T > Base& operator =( const T& t ) ;
  } ;

class Derived : public Base
  {
  } ;

Notiamo l'uso dei cosiddetti member template; solo alcuni compilatori li supportano gia' in modo corretto. In particolare, notiamo che il costruttore di copia template potrebbe erroneamente impedire anche la copia di oggetti di classe Base (ponendo T = Base). Questo invece non avviene proprio in virtu' di quanto discusso nello spazio ANSI/ISO: un costruttore template non e' mai un costruttore di copia. Un ragionamento analogo vale anche per l'operatore di assegnazione: un operatore di assegnazione template non si sovrappone a quello di default.
Le poche righe di cui sopra bastano a risolvere tutti i problemi. In particolare, il costruttore template risolve i punti (1), (2) e (4), mentre l'operatore di assegnazione template risolve il punto (3).
Una nota importante: su alcuni compilatori, come KAI C++, il codice di cui sopra funziona perfettamente. Altri, come il Visual C++ 5, non "capiscono" bene l'operatore di assegnazione template privato, e lasciano passare il punto (3) in fase di compilazione. Fortunatamente, poiche' abbiamo solo dichiarato ma non definito i due template, avremo poi un errore in fase di link (dove pero' diventa difficile risalire alla riga). Forse, prima o poi, Microsoft pensera' un po' meno ai wizard e ad amenita' come le flat toolbar nell'interfaccia dell'IDE, e mettera' invece a posto il nucleo del compilatore.

Controllo totale
Ora che abbiamo impedito lo slicing, chi usa la nostra classe Base e' "protetto" da quattro errori potenziali che avrebbe potuto commettere senza ricevere alcun messaggio di warning. Qualcuno obiettera' che abbiamo esagerato, che i linguaggi di programmazione non devono avere il complesso materno, eccetera.
Personalmente mi trovo in completo accordo con chi desidera "il controllo" sulla macchina e non vuole che il linguaggio prenda troppe decisioni al posto suo. Ma in realta', la nostra modifica alla classe Base va proprio nella direzione migliore. Ora il compilatore non potra' piu' decidere arbitrariamente (e dietro le nostre spalle) di trasformare un valore (non un puntatore o reference) di classe derivata in uno di classe Base, "affettandolo" senza dirci nulla. Ma il programmatore e' libero di farlo, chiedendolo esplicitamente.
Vediamo come: supponiamo di voler effettivamente chiamare la funzione f passando un oggetto Derived per valore, trasformandolo quindi in un oggetto Base. Ovviamente il codice di cui sopra non funziona:

void f( Base b )
  {
  }

void g()
  {
  Derived d ;
  f( d ) ;     // errore!
  }

Basta pero' chiedere la conversione esplicitamente:
void g()
  {
  Derived d ;
  f( static_cast< const Base& >( d ) ) ;
  }

Osserviamo bene quello che succede. Noi chiediamo una conversione di d in un reference a Base. Questa e' una conversione lecita, alla base del polimorfismo che vogliamo conservare. Poi chiamiamo f usando quel reference come argomento: a questo punto il compilatore chiama il costruttore di copia per la classe Base, e tutto funziona a puntino. La stessa tecnica si puo' ovviamente applicare anche agli altri casi, ad esempio in un costruttore:

Base b( static_cast< const Base& >( d ) ) ;

Uso ed effetti collaterali

A dire il vero la soluzione funziona cosi' bene che varrebbe la pena di inserirla in ogni classe di libreria, evitando cosi' anche agli utilizzatori meno esperti eventuali problemi di slicing.
Vi sono solo due punti importanti a cui prestare attenzione. Il primo e' che la soluzione non e' ereditata. Se qualcuno deriva DoubleDerived da Derived, lo slicing tra DoubleDerived e Derived e' (ovviamente, o forse non cosi' ovviamente) possibile. Dovremmo quindi "decorare" ogni classe con le righe di cui sopra. Ovviamente possiamo pensare di usare una macro:

#define NO_SLICE \
private : \
    template< class T > Base( const T& t ) ;  \
    template< class T > Base& operator =( const T& t ) ;

da usare come segue:
class Base
  {
  NO_SLICE
  // altro
  } ;

class Derived
  {
  NO_SLICE
  // ...
  } ;

Purtroppo non credo si riesca a fare meglio di cosi', proprio perche' i costruttori di copia non vengono ereditati.
Rimane un ulteriore punto da chiarire: se introduciamo NO_SLICE nelle nostre classi, richiediamo al programmatore che le usa di essere piu' chiaro nell'uso dei tipi degli oggetti passati ai costruttori. Vediamo un semplice esempio:

class Base
  {
  NO_SLICE
  public :
    Base();
    Base( int ) ;
  } ;

Base b( 'a' ) ; // errore
La costruzione di b fallisce, mentre in assenza di NO_SLICE avrebbe avuto successo, chiamando il costruttore Base( int ). Questo avviene perche' esiste una istanziazione del costruttore template privato (T = char) che non richiede alcuna conversione implicita sul parametro, mentre la chiamata al costruttore Base( int ) richiede una conversione implicita char -> int.
Naturalmente, basta che il programmatore sia piu' preciso:
Base b( static_cast< int >( 'a' ) ) ;
// ovviamente funziona anche la sintassi old-style:
// Base b( (int)'a' ) ;
Tutto sommato, quindi, ritengo che la macro NO_SLICE vada esattamente nella direzione migliore: meno sorprese, meno iniziative pericolose da parte del compilatore, e completo controllo al programmatore che voglia comunque "violare le regole". Chiaramente non mi aspetto che tutti siano d'accordo: le conversioni implicite hanno i loro fan, e questa e' la rubrica No Limits, non Religious War.

Conclusioni Chi segue i miei articoli su Computer Programming sa che non sono un fedele seguace della Programmazione Generica come metodo di design del software. Tuttavia, gran parte delle estensioni apportate al C++ negli ultimi anni riguardano i template, e sarebbe stupido non sfruttarli a fondo. Come avete visto nei vari numeri di C++ Informer, al di la' dei contenitori e degli algoritmi generici esistono utilizzi "creativi" dei template molto interessanti. Questa puntata di No Limits nasce da una discussione con il responsabile Ricerca e Sviluppo di una azienda mia cliente: dopo essere stati "colpiti" dallo slicing in troppe occasioni, cercavano una possibile soluzione preventiva. Sicuramente molti di voi si saranno scontrati con problemi piu' o meno "impossibili" da risolvere in C++. Se i vostri problemi riguardano il linguaggio in se', e non un pur interessante (ma fuori tema) dilemma con gli ActiveX, scrivetemi qualche riga via email. No Limits e' sempre alla ricerca di nuovi problemi "impossibili" per tentare di risolverli.

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.