MindView Inc.

[ Suggerimenti ] [ Soluzioni degli Esercizi] [ Volume 2 ] [ Newsletter Gratuita ]
[
Seminari ] [ Seminari su CD ROM ] [ Consulenza]

Pensare in C++, seconda ed. Volume 1

©2000 by Bruce Eckel

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]

trad. italiana e adattamento a cura di Gianmaria De Tommasi

15: Polimorfismo & Funzioni Virtuali

Il polimorfismo (implementato in C++ mediante le funzioni virtuali) è la terza caratteristica fondamentale di un linguaggio di programmazione object-oriented, dopo l’astrazione dei dati e l’ereditarietà.

Il polimorfismo fornisce un ulteriore grado di separazione tra l’interfaccia e l’implementazione, per separare il “cosa fare” dal “come farlo”. Il polimorfismo permette una migliore organizzazione e leggibilità del codice, così come la creazione di programmi espandibili, cioè capaci di “crescere” non solo durante la prima stesura del codice, ma anche quando si desidera aggiungere nuove funzionalità.

L’incapsulamento permette di creare nuovi tipi mettendo insieme i dati (membri) e i servizi (metodi). Il controllo d’accesso separa l’interfaccia dall’implementazione dichiarando tutti i dettagli come privati. Questo modo di strutturare il codice ha un senso e può essere facilmente capito da chi proviene dalla programmazione strutturata. Le funzioni virtuali, invece, riguardano il concetto di separazione dei tipi. Nel capitolo 14, è stato mostrato come l’ereditarietà permetta di trattare un oggetto come un’istanza della propria classe oppure della classe base. Questa possibilità è critica perché permette che tipi diversi (derivati dallo stesso tipo base) vengano trattati come un tipo unico e che un pezzo di codice possa funzionare nello stesso modo con tutti i tipi. L’uso delle funzioni virtuali permette di esprimere le differenze di comportamento tra tipi derivati dallo stesso tipo base. Questa distinzione è espressa mediante comportamenti diversi de

In questo capitolo, verranno trattate le funzioni virtuali, partendo dai principi con esempi semplici che rimuoveranno il concetto di programma “virtuale”.

Evoluzione dei programmatori C++

Sembra che i programmatori C imparino il C++ in tre passi. Il primo, semplicemente come un “C migliore”, perché il C++ obbliga a dichiarare tutte le funzioni prima che queste vengano usate ed a scegliere attentamente come usare le variabili. Spesso si possono trovare gli errori in un programma C semplicemente compilandolo con un compilatore C++.

Il secondo passo è quello di imparare un C++ “basato su oggetti”. In questa fase si apprezzano i benefici portati all’organizzazione del codice dal raggruppare le strutture dati insieme alle funzioni che lavorano su di esse, il valore dei costruttori, dei distruttori e la possibilità di utilizzare un primo livello di ereditarietà. Molti programmatori che hanno lavorato con il C per un periodo, notano subito l’utilità di tutto ciò perché, ogni volta che creano una libreria, questo è quello che cercano di fare. Con il C++,  in questo, si viene aiutati dal compilatore.

Ci si può fermare a questo livello ed utilizzare il C++ come un linguaggio basato su oggetti, perché si arriva presto a questo livello di comprensione e si ottengono molti benefici senza un grande sforzo intellettuale. Si creano tipi di dati, si costruiscono classi e oggetti, si mandano messaggi a questi oggetti e tutto sembra bello e semplice.

Ma non bisogna farsi ingannare. Se ci si ferma a questo punto, si lascia da parte la parte più grande del C++, vale a dire il passaggio alla vera programmazione object-oriented. Questa può essere fatta solo con l’uso delle funzioni virtuali.

Le funzioni virtuali ampliano il concetto di tipo e sono qualcosa di diverso rispetto all’incapsulamento del codice in strutture e dietro muri che rendono parte dell’implementazione inaccessibile; senza dubbio, quindi, le funzioni virtuali rappresentano il concetto più difficile da affrontare per i programmatori C++ alle prime armi. Allo stesso tempo, rappresentano anche il punto di svolta nella comprensione della programmazione object-oriented. Se non si usano funzioni virtuali, non si è ancora capita l’OOP.

Siccome il concetto di funzione virtuale è legato intimamente con quello di tipo ed il tipo è il cuore della programmazione object-oriented, non c’è niente di analogo alle funzioni virtuali in un linguaggio di programmazione procedurale tradizionale. Dal punto di vista di un programmatore "procedurale”, non c’è nessun riferimento a cui pensare per tracciare un analogia con le funzioni virtuali, al contrario delle altri aspetti caratteristici del C++. Le caratteristiche di un linguaggio procedurale possono essere capite da un punto di vista algoritmico, mentre le funzioni virtuali possono essere capite solo dal punto di visto della progettazione del software.

Upcasting

Nel capitolo 14 si è visto come un oggetto può essere usato come se stesso oppure come un istanza della tipo base. Un oggetto può essere manipolato anche attraverso un indirizzo del tipo base. L’operazione di usare l’indirizzo di un oggetto (un puntatore o un riferimento) e di trattarlo come l’indirizzo al tipo base viene chiamata upcasting (casting “all’insù”) perché i diagrammi delle classi vengono disegnati con la classe base in cima.

Si può notare che sorge presto un problema, come mostrato dal codice seguente:

//: C15:Instrument2.cpp
// Ereditarietà & upcast
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Eflat }; // Etc.
 
class Instrument {
public:
  void play(note) const {
    cout << "Instrument::play" << endl;
  }
};
 
// Gli oggetti Wind sono Instruments
// perché hanno la stessa interfaccia:
class Wind : public Instrument {
public:
  // Ridefinisce la funzione d’interfaccia:
  void play(note) const {
    cout << "Wind::play" << endl;
  }
};
 
void tune(Instrument& i) {
  // ...
  i.play(middleC);
}
 
int main() {
  Wind flute;
  tune(flute); // Upcast
} ///:~

La funzione tune() accetta (come riferimento) un oggetto Instrument, e , anche, senza nessuna segnalazione di errore, qualsiasi oggetto derivato da Instrument. Nel main(), si può vedere come questo avvenga quando un oggetto Wind viene passato a tune(), senza nessun bisogno di operazioni di cast. Questo è possibile; l’interfaccia di Instrument esiste anche in Wind, perché Wind è ereditato pubblicamente da Instrument. L’upcasting da Wind a Instrument può “restringere” l’interfaccia di Wind, ma non si potrà mai ottenere un oggetto con un interfaccia più piccola di quella di Instrument.

Le stesse considerazioni valgono anche quando si lavora con i puntatori; l’unica differenza è che l’utente deve fornire esplicitamente l’indirizzo dell’oggetto così come questo deve essere passato alla funzione.

Il problema

Il problema presente in Instrument2.cpp può essere visto semplicemente mandando in esecuzione il programma. L’uscita è quella di Instrument::play. Ovviamente questo non è il comportamento desiderato, perché in questo caso è noto che l’oggetto in questione è un Wind e non solo un Instrument. La chiamata alla funzione tune() dovrebbe produrre una chiamato a Wind::play. A questo scopo, ogni istanza di una classe derivata da Instrument dovrebbe avere la propria versione di play() da usare in qualsiasi situazione.

Il comportamento di Instrument2.cpp non sorprende, se si utilizza un approccio alle funzioni in stile C. Per capire la questione, bisogna introdurre il concetto di binding.

Binding delle chiamate a funzioni

Il collegamento della chiamata ad una funzione con il corpo della funzione stessa viene chiamato binding. Nel caso in cui il binding venga fatto prima che il programma vada in esecuzione (dal compilatore e dal linker), si ha l’early binding. Il termine binding potrebbe risultare sconosciuto ai più, perché nel caso dei linguaggi procedurali non ci sono alternative: i compilatori C hanno un solo metodo per realizzare le chiamate alle funzioni ed è l’early binding.

Il problema nel programma precedente è causato dall’early binding, perché il compilatore non può conoscere la funzione corretta da chiamare se ha a disposizione solo l’indirizzo di un un oggetto Instrument.

La soluzione è chiamata  late binding, che consiste nel binding effettuato a runtime, basato sul tipo di oggetto. Il late binding viene anche chiamato binding dinamico o binding a runtime. Quando un linguaggio implementa il late binding, devono esserci dei meccanismi per poter determinare il tipo dell’oggetto a runtime e chiamare la funzione membro appropriata. Nel caso di un linguaggio compilato, il compilatore non conosce il tipo dell’oggetto che verrà passato alla funzione, ma inserisce del codice per trovare e chiamare la funzione corretta. Il meccanismo del late binding varia da linguaggio a linguaggio, ma, in ogni caso, si può pensare che alcune informazioni devono essere inserite negli oggetti. Più avanti in questo capitolo verrà mostrato il funzionamento di questo meccanismo.

funzioni virtuali

Per fare in modo che venga effettuato il late binding per una particolare funzione, il C++ impone di utilizzare la parola chiave virtual quando viene dichiarata la funzione nella classe base. Il late binding viene realizzato solo per le funzioni virtuali, solo quando viene utilizzato un indirizzo della classe base in cui esistono delle funzioni virtuali; queste funzioni devono anche essere state definite nella classe base.

Per creare una funzione come virtuale, bisogna semplicemente far precedere la dichiarazione della funzione dalla parola chiave virtual. Solo la dichiarazione richiede l’uso della parola chiave virtual e non la definizione. Se una funzione viene dichiarata come virtuale nella classe base, resterà virtuale per tutte le classi derivate. La ridefinizione di una funzione virtuale in una classe derivata viene solitamente chiamata overriding.

Bisogna notare che l’unica cosa necessaria da fare è quella di dichiarare una funzione come virtual nella classe base. Tutte le funzioni delle classi derivate che avranno lo stesso prototipo verranno chiamate utilizzando il meccanismo delle funzioni virtuali. La parola chiave virtual può essere usata nelle dichiarazioni delle classi derivate (non c’è nessun pericolo nel farlo), ma è ridondante è può causare confusione.

Per ottenere il comportamento desiderato da Instrument2.cpp, basta semplicemente aggiungere la parola chiave virtual nella classe base prima di play().

//: C15:Instrument3.cpp
// Late binding con parola chiave virtual
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
 
class Instrument {
public:
  virtual void play(note) const {
    cout << "Instrument::play" << endl;
  }
};
 
// Gli oggetti Wind sono Instruments
// perché hanno la stessa interfaccia:
class Wind : public Instrument {
public:
  // Override della funzione d’interfaccia:
  void play(note) const {
    cout << "Wind::play" << endl;
  }
};
 
void tune(Instrument& i) {
  // ...
  i.play(middleC);
}
 
int main() {
  Wind flute;
  tune(flute); // Upcast
} ///:~

Questo listato è identico a quella di Instrument2.cpp tranne per l’aggiunta della parola chiave virtual, ma il suo comportamento è diverso in maniera significativa: ora l’uscita corrisponde a quella di Wind::play.

Estendibilità

Avendo definito play() come virtuale nella classe base, si possono aggiungere tanti nuovi tipi quanti se ne desidera senza dover cambiare la funzione tune(). In un programma OOP ben progettato, la maggior parte delle funzioni dovrà ricalcare il modello di tune() e comunicare solo con l’interfaccia della classe base. Un programma di questo tipo è estendibile perché si possono aggiungere nuove funzionalità ereditando nuovi tipi di dati da una classe base comune. Le funzioni che interagiscono con l’interfaccia della classe base non dovranno essere cambiate per poter lavorare anche con le nuove classi.

Di seguito è riportato l’esempio degli strumenti con più funzioni virtuali e con un numero maggiore di classi nuove, le quali funzionano tutte correttamente con la vecchia funzione tune() che è rimasta invariata:

//: C15:Instrument4.cpp
// Estendibilità in OOP
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
 
class Instrument {
public:
  virtual void play(note) const {
    cout << "Instrument::play" << endl;
  }
  virtual char* what() const {
    return "Instrument";
  }
  // Si supponga che adjust() modifichi l’oggetto:
  virtual void adjust(int) {}
};
 
class Wind : public Instrument {
public:
  void play(note) const {
    cout << "Wind::play" << endl;
  }
  char* what() const { return "Wind"; }
  void adjust(int) {}
};
 
class Percussion : public Instrument {
public:
  void play(note) const {
    cout << "Percussion::play" << endl;
  }
  char* what() const { return "Percussion"; }
  void adjust(int) {}
};
 
class Stringed : public Instrument {
public:
  void play(note) const {
    cout << "Stringed::play" << endl;
  }
  char* what() const { return "Stringed"; }
  void adjust(int) {}
};
 
class Brass : public Wind {
public:
  void play(note) const {
    cout << "Brass::play" << endl;
  }
  char* what() const { return "Brass"; }
};
 
class Woodwind : public Wind {
public:
  void play(note) const {
    cout << "Woodwind::play" << endl;
  }
  char* what() const { return "Woodwind"; }
};
 
// Identica alla funzione di prima:
void tune(Instrument& i) {
  // ...
  i.play(middleC);
}
 
// Funzione nuova:
void f(Instrument& i) { i.adjust(1); }
 
// Upcast durante l’inizializzazione dell’array:
Instrument* A[] = {
  new Wind,
  new Percussion,
  new Stringed,
  new Brass,
};
 
int main() {
  Wind flute;
  Percussion drum;
  Stringed violin;
  Brass flugelhorn;
  Woodwind recorder;
  tune(flute);
  tune(drum);
  tune(violin);
  tune(flugelhorn);
  tune(recorder);
  f(flugelhorn);
} ///:~

Si noti che un altro livello di ereditarietà è stato derivato a partire da Wind, ma il meccanismo delle funzioni virtuali continua a funzionare in maniera corretta indipendentemente dal numero di livelli di derivazione delle classi. Per la funzione adjust() delle classi Brass e Woodwind non è stato applicato l’override. In questo caso, la definizione gerarchicamente “più vicina” viene usata automaticamente – il compilatore garantisce che ci sia sempre una definizione per una funzione virtuale, quindi una chiamata ad una funzione sarà sempre collegata al corpo di una funzione. (se questo non avvenisse sarebbe disastroso).

L’array A[] contiene dei puntatori ad oggetti derivati dalla classe base Instrument, quindi delle operazione di upcasting verranno fatte durante l’inizializzazione. Questo array e la funzione f() verranno presi in considerazione di nuovo più avanti in questa trattazione.

Nella chiamata a tune(), l’upcasting viene fatto per ogni tipo di oggetto differente, in maniera da ottenere ogni volta il comportamento desiderato. Questo comportamento può essere descritto nella maniera seguente: “un messaggio viene mandato ad un oggetto e a questo viene lasciato l’onere di interpretare il messaggio ed eseguire le relative operazioni”. Le funzioni virtuali sono lo strumento da utilizzare quando si fa l’analisi durante la progettazione del software: A quale livello collocare le classi base? Come si vuole estendere il programma? In ogni modo, anche se non viene individuata la classe base appropriata e le funzioni virtuali nella prima stesura del programma, possono sempre essere scoperte più avanti, anche molto tardi, quando si pensa a come estendere oppure a come manutenere il programma. Questo modo di procedere non va considerato come un errore di analisi o di progetto; significa, semplicemente, che non si hanno o non si possono avere tutte le informazioni all’inizio del processo di sviluppo del software. Grazie alla modularizzazione in classi che si ha utilizzando il C++, il dover apportare modifiche al progetto non rappresenta un grande problema, perché i cambiamenti fatti in una parte del sistema tendono a non propagarsi in altre parti del sistema stesso, al contrario di quanto accade con il C.

Come viene realizzato il late binding in C++

Cosa succede quando viene fatto il late binding? Tutto il lavoro viene fatto dietro il sipario dal compilatore, che installa i meccanismi necessari per il late binding quando ne viene fatta richiesta (la richiesta viene fatta creando funzioni virtuali). Dato che i programmatori spesso traggono beneficio dalla comprensione del meccanismo delle funzioni virtuali in C++, questo paragrafo è incentrato sul modo in cui il compilatore implementa questo meccanismo.

La parola chiave virtual comunica al compilatore che non dovrà realizzare l’early binding. Al suo posto, dovrà automaticamente installare i meccanismi necessari per realizzare il late binding. Questo vuol dire che se verrà chiamata la funzione play() per un oggetto Brass attraverso l’indirizzo della classe base Instrument, si dovrà ottenere una chiamata alla funzione corretta.

Per realizzare questo meccanismo, il compilatore tipico [54] crea una tabella (chiamata VTABLE)  per ogni classe che contiene funzioni virtuali. Il compilatore inserisce gli indirizzi delle funzioni virtuali di una classe particolare nella VTABLE. In ogni classe con funzioni virtuali, viene messo, in maniera segreta e non visibile al programmatore, un puntatore chiamato vpointer (abbreviato in VPTR), che punta alla VTABLE corrispondente alla classe. Quando viene fatta una chiamata ad una funzione virtuale attraverso un puntatore alla classe base (questo avviene quando viene fatta una chiamata polimorfica ad una funzione), il compilatore semplicemente inserisce del codice per caricare il VPTR e ottenere l’indirizzo della funzione contenuto della VTABLE, in modo da chiamare la funzione corretta e far si che il late binding abbia luogo.

Tutto questo – la creazione di una VTABLE per ogni classe, l’inizializzazione del VPTR, l’inserzione del codice per la chiamata ad una funzione virtuale – avviene in maniera automatica. Con le funzioni virtuali, per ogni oggetto viene chiamata la funzione corretta, anche se il compilatore non conosce lo specifico oggetto.

La sezione seguente illustrerà nel dettaglio questo meccanismo.

Memorizzazione dell’informazione sul tipo

Come si è visto precedentemente, in ogni classe non è memorizzata esplicitamente alcuna informazione riguardante il tipo. Gli esempi precedenti ed il buonsenso, però, portano a pensare che questa informazione deve essere memorizzata negli oggetti, altrimenti il tipo non potrebbe essere stabilito durante l’esecuzione del programma. Questa informazione c’è, quindi, ma è nascosta. Ecco un esempio che mette in evidenza la presenza di questa informazione confrontando le dimensioni delle classi che fanno uso di funzioni virtuali e delle classi che non ne fanno uso:

//: C15:Sizes.cpp
// Dimensione di oggetti con o senza funzioni virtuali
#include <iostream>
using namespace std;
 
class NoVirtual {
  int a;
public:
  void x() const {}
  int i() const { return 1; }
};
 
class OneVirtual {
  int a;
public:
  virtual void x() const {}
  int i() const { return 1; }
};
 
class TwoVirtuals {
  int a;
public:
  virtual void x() const {}
  virtual int i() const { return 1; }
};
 
int main() {
  cout << "int: " << sizeof(int) << endl;
  cout << "NoVirtual: "
       << sizeof(NoVirtual) << endl;
  cout << "void* : " << sizeof(void*) << endl;
  cout << "OneVirtual: "
       << sizeof(OneVirtual) << endl;
  cout << "TwoVirtuals: "
       << sizeof(TwoVirtuals) << endl;
} ///:~

Senza funzioni virtuali, la dimensione dell’oggetto è esattamente quella che ci si aspetterebbe : la dimensione di un singolo[55] int. OneVirtual ha una sola funzione virtuale e la dimensione di una sua istanza è pari a quella di un’istanza di NoVirtual sommata alla dimensione di un puntatore a void. Da questo esempio si deduce che il compilatore inserisce un unico puntatore (il VPTR) se c’è almeno una funzione virtuale. Infatti non c’è alcuna differenza tra le dimensioni di OneVirtual e TwoVirtual. Questo accade perché tutti gli indirizzi delle funzioni virtuali sono contenute in un’unica tabella.

In questo esempio è necessario che ci sia almeno un dato membro nelle classi. Se non ci fossero stati dati membri, infatti, il compilatore C++ avrebbe forzato gli oggetti ad avere dimensione diversa da zero, perché ogni oggetto deve avere un proprio indirizzo diverso dagli altri. Per poter capire meglio questa affermazione si provi ad immaginare come si possa accedere agli elementi di un array di oggetti a dimensione nulla. Un membro fittizio viene inserito negli oggetti, che altrimenti avrebbero dimensione nulla. Quando viene utilizzata la parola chiave virtual, viene inserita  l’informazione riguardane il tipo che prende il posto del membro fittizio. Si provi a commentare int a in tutte le classi dell’esempio precedente per osservare questo comportamento.

Rappresentazione delle funzioni virtuali

Per comprendere esattamente cosa succede quando viene utilizzata una funzione virtuale, può essere d’aiuto visualizzare le operazioni che avvengono dietro il sipario. Di seguito è mostrato uno schema dell’array di puntatori A[] presente in Instrument4.cpp:

L’array di puntatori a Instrument non ha nessuna informazione specifica; ogni puntatore punta ad un oggetto di tipo Instrument. Wind, Percussion, Stringed e Brass rientrano tutti in questa categoria perché derivati da Instrument (quindi hanno la stessa interfaccia di Instrument e possono rispondere agli stessi messaggi), quindi i loro indirizzi possono essere memorizzati nell’array. Ciononostante, il compilatore non conosce che loro sono qualcosa in più rispetto a degli oggetti Instrument, quindi, normalmente, dovrebbe chiamare la versione della classe base di tutte le funzioni. In questo caso, però, tutte le funzioni sono state dichiarate utilizzando la parola chiave virtual, quindi succede qualcosa di diverso.

Ogni volta che viene creata una classe che contiene delle funzioni virtuali, oppure ogni volta che viene derivata una classe da una che contiene funzioni virtuali, il compilatore crea un’unica VTABLE per ogni classe, come mostrato alla destra dello schema precedente. In questa tabella il compilatore inserisce gli indirizzi di tutte le funzioni che sono dichiarate come virtuali in quella classe oppure nella classe base. Se non viene fatto l’override di una funzione dichiarata virtuale nella classe base, il compilatore usa l’indirizzo della versione della classe base anche nella classe derivata. (Questo comportamento avviene per la funzione adjust nella VTABLE di Brass). Successivamente, il compilatore inserisce il VPTR (scoperto nell’esempio Sizes.cpp) nella classe. Quando viene utilizzata l’ereditarietà semplice, come in questo caso, c’è un solo VPTR per ogni oggetto. Il VPTR deve essere inizializzato in maniera tale da puntare all’indirizzo iniziale della VTALBE relativa a quella classe. (Questo avviene nel costruttore, come si vedrà in dettaglio più avanti).

Una volta inizializzato il VPTR in modo che punti alla VTABLE appropriata, è come se l’oggetto “conoscesse” il proprio tipo. Ma questa auto-coscienza non serve a nulla fino a quando non viene chiamata una funzione virtuale.

Quando viene chiamata una funzione virtuale attraverso l’indirizzo ad un oggetto della classe base (che è la situazione in cui il compilatore non ha tutte le informazioni necessarie per poter fare l’earl binding), accade qualcosa di speciale. Invece di effettuare un tipica chiamata ad una funzione, che è semplicemente un’istruzione CALL in linguaggio assembler ad un particolare indirizzo, il compilatore genera del codice differente. Ecco cosa avviene quando viene fatta una chiamata ad adjust() per un oggetto Brass mediante un puntatore a Instrument (un riferimento a Instrument produce lo stesso comportamento):

Il compilatore parte dal puntatore a Instrument, che punta all’indirizzo iniziale dell’oggetto. Tutti gli oggetti di tipo Instrument e quelli che derivano da Instrument hanno il loro VPTR nello stesso posto (spesso all’inizio dell’oggetto), quindi il compilatore può prelevare il VPTR dall’oggetto. Il VPTR punta all’indirizzo iniziale di VTABLE. Tutti gli indirizzi delle funzioni in VTABLE sono disposti nello stesso ordine, a prescindere dal tipo particolare di oggetto. L’indirizzo di play()  è il primo, quello di what() è il secondo e quello di adjust() è il terzo. Il compilatore conosce che, per qualsiasi oggetto particolare, il puntatore a adjust() è alla locazione VPTR+2. Quindi, invece di dire “Chiama la funzione all’indirizzo assoluto Instrument::adjust” (early-binding, il comportamento errato in questo caso), viene generato del codice che dice, di fatto, “Chiama la funzione all’indirizzo contenuto nella locazione VPTR+2”. Dato che il caricamento di VPTR e la determinazione dell’indirizzo avviene durante l’esecuzione, si ottiene il desiderato late binding. Il messaggio viene mandato all’oggetto e l’oggetto lo elabora.

Sotto il cappello

Può essere d’aiuto prendere in considerazione il codice assembler generato da una chiamata ad una funzione virtuale, per vedere come avviene il meccanismo di late-binding. Di seguito è mostrata l’uscita di un compilatore per la chiamata al metodo

i.adjust(1);

all’interno della funzione f(Instrument& i):

push  1
push  si
mov   bx, word ptr [si]
call  word ptr [bx+4]
add   sp, 4

I parametri di una chiamata ad una funzione C++, così come avviene per una chiamata ad una funzione C, vengono inseriti in cima allo stack a partendo da destra verso sinistra (questo tipo di ordinamento è richiesto per poter supportare le liste di argomenti del C), quindi il parametro 1 viene inserito per primo nello stack. A questo punto nella funzione, il registro si (un registro presente nei processori con architettura Intel X86) contiene l’indirizzo di i. Anche il contenuto di questo registro viene inserito nello stack essendo l’indirizzo di partenza dell’oggetto preso in considerazione. Bisogna ricordare che l’indirizzo iniziale di un oggetto corrisponde al valore di this e this viene sempre inserito nello stack come se fosse un parametro prima di qualsiasi chiamata ad una funzione membro, in questo modo la funzione membro conosce su quale oggetto particolare sta lavorando. Quindi, nello stack, verrà inserito sempre un parametro in più rispetto al numero di parametri reali prima di fare la chiamata alla funzione (eccezione fatta per le funzioni membro definite come static, per le quali this non è definito).

A questo punto può essere fatta la chiamata alla funzione virtuale vera e propria. Come prima cosa, bisogna ricavare il valore di VPTR, in modo da poter trovare la VTABLE. Per il compilatore preso in considerazione il VPTR è inserito all’inizio dell’oggetto, quindi this punta alla locazione di memoria in cui si trova VPTR. La linea

mov bx, word ptr [si]

carica la word puntata da si (cioè da this), corrispondente al VPTR. VPTR viene caricato nel registro bx.

Il VPTR contenuto in bx punta all’indirizzo iniziale di VTABLE, ma il puntatore alla funzione da chiamare non si trova alla locazione zero di VTABLE, bensì nella seconda locazione (essendo la terza funzione nella lista). Per questo modello di memoria, ogni puntatore a funzione è lungo due byte, per questo motivo il compilatore aggiunge quattro al valore di VPTR per calcolare l’indirizzo esatto della funzione da chiamare. Si noti che questo che questo è un valore costante, stabilito durante la compilazione, quindi l’unica cosa che bisogna conoscere è che il puntatore a funzione che si trova nella seconda locazione è quello che punta a adjust(). Fortunatamente, il compilatore si prende cura di mettere le cose in ordine, assicurando che tutti i puntatori a funzione in tutte le VTABLE di una particolare gerarchia di classi siano disposti nello stesso ordine, non tenendo conto dell’ordine in cui è stato fatto l’override nelle classi derivate.

Una volta determinato l’indirizzo del puntatore alla funzione opportuna nella VTABLE, la funzione viene chiamata. Quindi l’indirizzo viene caricato e puntato con l’unica istruzione

call word ptr [bx+4]

Infine, il puntatore allo stack viene mosso in basso per liberare la memoria dai parametri che erano stati inseriti prima della chiamata. Nel codice assembler generato dal C e dal C++ spesso si può avere che la memoria della stack venga deallocata dalla funzione chiamante, ma questo può variare a seconda del processore e dell’implementazione del compilatore.

Installare il vpointer

Dato che il VPTR rende possibile il comportamento virtuale di un oggetto, si può notare come sia importante e critico che il VPTR punti sempre alla VTABLE esatta. Non deve essere possibile chiamare una funzione virtuale prima che il VPTR sia propriamente inizializzato. Solo se un’azione viene messa nel costruttore c’è la garanzia che questa azione venga eseguita, ma in nessuno degli esempi visti fin ora Instrument aveva un costruttore.

Questo è il caso in cui la creazione di un costruttore di default è essenziale. Negli esempi visti, il compilatore crea un costruttore di default che non fa altro che inizializzare il VPTR. Questo costruttore, quindi, viene chiamato automaticamente per ognuno degli oggetti Instrument prima che si possa fare qualsiasi cosa con essi, quindi si può essere certi che la chiamata ad una funzione virtuale è sempre un’operazione sicura.

Le implicazioni dovute all’inizializzazione automatica del VPTR all’interno del costruttore verranno prese in considerazione in un paragrafo successivo.

Gli oggetti sono differenti

È importante capire che l’upcasting funziona solo con gli indirizzi. Se il compilatore sta processando un oggetto, ne conosce il tipo e quindi (in C++) non utilizzerà il meccanismo del late binding. Per motivi d’efficienza,  molti compilatori faranno l’early binding anche per la chiamata ad una funzione virtuale quando possono risalire esattamente al tipo d’oggetto. Ecco un esempio:

//: C15:Early.cpp
// Early binding & e funzioni virtuali
#include <iostream>
#include <string>
using namespace std;
 
class Pet {
public:
  virtual string speak() const { return ""; }
};
 
class Dog : public Pet {
public:
  string speak() const { return "Bark!"; }
};
 
int main() {
  Dog ralph;
  Pet* p1 = &ralph;
  Pet& p2 = ralph;
  Pet p3;
  // Late binding per entrambi:
  cout << "p1->speak() = " << p1->speak() <<endl;
  cout << "p2.speak() = " << p2.speak() << endl;
  // Early binding (probabilmente):
  cout << "p3.speak() = " << p3.speak() << endl;
} ///:~

In p1–>speak( ) e p2.speak( ), vengono utilizzati gli indirizzi, quindi l’informazione a disposizione è incompleta: p1 e p2 possono rappresentare l’indirizzo di un Pet oppure di qualcosa derivato da Pet, quindi deve essere utilizzato il meccanismo virtuale. Quando si chiama p3.speak() non c’è ambiguità. Il compilatore conosce il tipo esatto e questo è un oggetto,  ma non un oggetto derivato da Pet, bensì esattamente un Pet. Quindi, probabilmente, in questo caso viene usato l’earl binding. Comunque, se il compilatore non vuole fare un lavoro troppo complesso, può comunque utilizzare il late binding è si otterrà lo stesso comportamento finale.

Perché le funzioni virtuali?

A questo punto si potrebbe porre una domanda: “Se questa tecnica è così importante e se permette che ogni volta venga chiamata la funzione ‘giusta’, perché viene data come opzione? Perché si deve farne esplicitamente riferimento?”

La risposta  a questa bella domanda si trova nella filosofia fondamentale del C++: “Perché non è molto efficiente.” Si può notare dal codice assembler generato precedentemente, che invece di una semplice  CALL ad un indirizzo assoluto, sono necessarie due – più complesse – istruzioni assembler per realizzare la chiamata alla funzione virtuale. Questo richiede spazio in memoria per il codice e tempo di esecuzione.

Alcuni linguaggi orientati ad oggetti hanno adottato l’approccio di utilizzare sempre il meccanismo del  late binding, perché viene considerato intrinsecamente legato alla programmazione orientata agli oggetti; non è più un opzione ed il programmatore non deve tenerne conto. Questa è una scelta di progetto quando viene creato un linguaggio e questo approccio è appropriato per molti linguaggi.[56] Il C++, invece, deriva dal C, dove l’aspetto dell’efficienza è critico. Dopotutto, il C fu creato per prendere il posto del linguaggio assembler nella realizzazione di un sistema operativo (con il risultato di rendere questo sistema operativo – Unix – molto più portabile rispetto ai suoi predecessori). Una delle ragioni principali che hanno portato all’invenzione del C++ è stata quella di rendere i programmatori C più efficienti. [57] E la prima domanda che viene posta quando un programmatore C si avvicina al C++ è, “Che impatto avrà sull’occupazione di memoria e sulla velocità d’esecuzione?” Se la risposta fosse, “Migliora tutto tranne per quanto riguarda le chiamate a funzione dove c’è sempre un piccolo aumento delle risorse richieste”, molte persone continuerebbero ad utilizzare il C piuttosto che passare al C++. Inoltre, non si potrebbero utilizzare le funzioni inline, perché le funzioni virtuali devono avere un indirizzo da poter mettere nella VTABLE. Per questi motivi la funzione virtuale è un opzione e il default del linguaggio è una funzione non virtuale, che rappresenta la configurazione più veloce. Stroustrup disse che la sua idea era, “Se non si utilizza, non si paga.”

Quindi, le parola chiave virtual viene fornita per migliorare l’efficienza. Quando si progettano le proprie classi, solitamente non ci si vuole preoccupare dell’efficienza. Se si sta utilizzando il polimorfismo, quindi, è bene utilizzare le funzioni virtuali ovunque. Si deve fare attenzione alle funzioni che possono essere realizzate come non-virtuali quando si vuole migliorare la velocità del proprio codice (e solitamente si ottengono risultati migliori lavorando su altri aspetti – un buon profiler potrà trovare i colli di bottiglia del programma in maniera più efficiente rispetto a quanto si potrebbe fare attraverso una semplice stima).

L’evidenze pratiche suggeriscono che il miglioramento in termini di occupazione di memoria e di velocità di esecuzione nel passare al C++ è di circa il 10 percento rispetto al C e spesso le prestazioni sono simili. La ragione per cui si riescono ad ottenere migliori prestazioni consiste nel fatto che un programma C++ può essere progettato e realizzato in maniera più veloce e occupando meno spazio rispetto a quanto si potrebbe fare utilizzando il C.

Classi base astratte e funzioni virtuali pure

Spesso nella progettazione, si vuole che una classe base implementi solo un’interfaccia per le sue classi derivate. In questo caso,si vuole che nessuno possa creare un oggetto della classe base, ma si vuole rendere possibile solo l’upcast a questa classe base in maniera da poter utilizzarne l’interfaccia. Questo risultato è raggiunto realizzando questa classe come astratta, cioè dotandola di almeno una funzione virtuale pura. Un funzione virtuale pura può essere riconosciuta perché utilizza la parola chiave virtual ed è seguita da =0. Se qualcuno prova ad istanziare un oggetto di una classe astratta, il compilatore lo impedirà. Questo meccanismo permette di forzare una particolare idea di progetto.

Quando una classe astratta viene ereditata, tutte le sue funzioni virtuali pure devono essere implementate, altrimenti la classe derivata sarà anch’essa astratta. La creazione di una funzione virtuale pura permette di inserire una funzione membro all’interno di un’interfaccia senza dover realizzare un implementazione della funzione stessa. Allo stesso tempo, una funzione virtuale pura forza le classi derivate a fornirne un’implementazione.

In tutti gli esempi con gli strumenti, le funzioni nella classe base Instrument erano funzioni fittizie, “dummy”. Se queste funzioni vengono chiamate, qualcosa non ha funzionato. Questo perché l’intento di Instrument è quello di creare un’interfaccia comune per tutte le classi che verranno derivate da essa.

L’unica ragione per realizzare l’interfaccia comune è quella di poterla specificare differentemente per ogni sottotipo. Crea la struttura base che determina cosa c’è in comune nelle classi derivate – nient’altro. Quindi Instrument è il giusto candidato per diventare una classe astratta. Si crea una classe astratta quando si vuole manipolare un insieme di classi attraverso un’interfaccia comune, ma quest’ultima non necessita un’implementazione (oppure, un’implementazione completa).

Se si ha un concetto simile a Instrument che si comporta come una classe astratta, oggetti appartenenti a questa classe non hanno mai significato. Instrument ha l’unico scopo di esprimere l’interfaccia e non un’implementazione particolare, quindi creare un oggetto che sia solo un Instrument non ha senso e probabilmente si vuole prevenire che un utente possa farlo. Questo può essere ottenuto realizzando tutte le funzioni virtuali di Instrument in maniera tale che stampino un messaggio d’errore; questo sposta la comunicazione di una situazione d’errore a runtime e richiede un test esaustivo del codice da parte dell’utente. È molto meglio risolvere il problema durante la compilazione.

Ecco la sintassi utilizzata per la dichiarazione di una funzione virtuale pura:

virtual void f() = 0;

Facendo questo, viene comunicato al compilatore di riservare uno spazio nella VTABLE per questa funzione, ma non viene messo alcun indirizzo in questo spazio. Quindi anche se una sola funzione viene dichiarata come virtuale pura, la VTABLE è incompleta.

Se la VTABLE per una classe è incompleta, cosa può fare il compilatore quando qualcuno cerca di creare un’istanza di questa classe? Dato che non può creare in maniera sicura un’istanza di una classe astratta, darà un messaggio d’errore. È il compilatore, quindi, a garantire la purezza di una classe astratta. Rendendo un classe astratta, ci si assicura che nessun programma che utilizzerà questa classe possa crearne un’istanza.

Di seguito è mostrato l’esempio Instrument4.cpp modificato in maniera da utilizzare le funzioni virtuali. Siccome tutti i metodi di questa classe sono funzioni virtuali pure, verrà chiamata classe astratta pura:

//: C15:Instrument5.cpp
// Classi base astratte pure
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
 
class Instrument {
public:
  // Funzioni virtuali pure:
  virtual void play(note) const = 0;
  virtual char* what() const = 0;
  // Si supponga che adjust() modifichi l’oggetto:
  virtual void adjust(int) = 0;
};
// La parte rimanente del file è identica...
 
class Wind : public Instrument {
public:
  void play(note) const {
    cout << "Wind::play" << endl;
  }
  char* what() const { return "Wind"; }
  void adjust(int) {}
};
 
class Percussion : public Instrument {
public:
  void play(note) const {
    cout << "Percussion::play" << endl;
  }
  char* what() const { return "Percussion"; }
  void adjust(int) {}
};
 
class Stringed : public Instrument {
public:
  void play(note) const {
    cout << "Stringed::play" << endl;
  }
  char* what() const { return "Stringed"; }
  void adjust(int) {}
};
 
class Brass : public Wind {
public:
  void play(note) const {
    cout << "Brass::play" << endl;
  }
  char* what() const { return "Brass"; }
};
 
class Woodwind : public Wind {
public:
  void play(note) const {
    cout << "Woodwind::play" << endl;
  }
  char* what() const { return "Woodwind"; }
};
 
// Identica alla funzione di prima:
void tune(Instrument& i) {
  // ...
  i.play(middleC);
}
 
// Funzione nuova:
void f(Instrument& i) { i.adjust(1); }
 
int main() {
  Wind flute;
  Percussion drum;
  Stringed violin;
  Brass flugelhorn;
  Woodwind recorder;
  tune(flute);
  tune(drum);
  tune(violin);
  tune(flugelhorn);
  tune(recorder);
  f(flugelhorn);
} ///:~

Le funzioni virtuali pure sono utili perché rendono esplicita l’astrattezza di una classe e comunicano sia all’utente che al compilatore come questa classe dovrebbe essere utilizzata.

Si noti che le funzioni virtuali pure impediscono che una classe astratta venga passata ad una funzione per valore. Perciò, rappresentano anche un metodo per prevenire il fenomeno dell’object slicing (che sarà descritto poco più avanti). Rendendo una classe astratta, si può essere sicuri che verrà sempre usato un puntatore o un riferimento durante un’operazione di upcasting alla classe astratta.

Il fatto che una funzione virtuale pura non permetta il completamento della VTABLE non significa che non ci possano essere situazioni in cui si vogliano specificare le altre funzioni della classe astratta. Spesso si vuole chiamare la versione della classe base di una funzione, anche se questa è virtuale. È buona abitudine porre il codice comune il più possibile nella radice della gerarchia delle classi. In questo modo non si risparmia solo memoria, ma si ha anche una più semplice propagazione delle modifiche.

Definizione di funzioni virtuali pure

È possibile dare una definizione ad una funzione virtuale pura in una classe base. In questo caso, si sta ancora comunicando al compilatore che non è permesso istanziare oggetti della classe astratta base e che le funzioni virtuali pure dovranno ancora essere definite nelle classi derivate in maniera da poter creare gli oggetti. Nonostante questo, ci potrebbe essere un pezzo di codice comune e si vuole che le classi derivate lo richiamino, piuttosto che duplicare questo codice in ogni funzione.

Ecco come si presenta la definizione di una funzione virtuale pura:

//: C15:PureVirtualDefinitions.cpp
// Definizioni di funzioni virtuali pure
#include <iostream>
using namespace std;
 
class Pet {
public:
  virtual void speak() const = 0;
  virtual void eat() const = 0;
  // Definizioni inline di funzioni virtuali pure non sono permesse:
  //!  virtual void sleep() const = 0 {}
};
 
// OK, non definita inline
void Pet::eat() const {
  cout << "Pet::eat()" << endl;
}
 
void Pet::speak() const { 
  cout << "Pet::speak()" << endl;
}
 
class Dog : public Pet {
public:
  // Usa il codice comune di Pet:
  void speak() const { Pet::speak(); }
  void eat() const { Pet::eat(); }
};
 
int main() {
  Dog simba;  // Il cane di Richard
  simba.speak();
  simba.eat();
} ///:~

Nella VTABLE di Pet continua ad esserci uno spazio vuoto, ma c’è una funzione che è possibile richiamare dalla classe derivata.

Un ulteriore vantaggio che si ottiene è la possibilità di cambiare una funzione virtuale ordinaria in una virtuale pura senza dover cambiare il codice esistente. (Questo può essere un modo per verificare quali sono le classi che non fanno l’override delle funzioni virtuali.)

Ereditarietà e VTABLE

Si è visto cosa succede quando si eredita da una classe e si fa l’override di alcune funzioni virtuali. Il compilatore crea una nuova VTABLE per la nuova classe e qui inserisce gli indirizzi delle nuove funzioni usando gli indirizzi delle funzioni della classe base per tutte le funzioni virtuali per le quali non è stato fatto l’override. In un modo o nell’altro, per ogni oggetto che può essere creato (cioè la cui classe di appartenenza non contenga funzioni virtuali pure) c’è sempre un insieme completo di indirizzi nella VTABLE, quindi non sarà possibile effettuare la chiamata ad un indirizzo diverso da quelli ivi contenuti (la qual cosa potrebbe essere disastrosa).

Ma cosa succede quando si eredità e si aggiunge una nuova funzione virtuale alla classe derivata? Ecco un esempio:

//: C15:AddingVirtuals.cpp
// Aggiungere funzioni virtuali nelle classi derivate
#include <iostream>
#include <string>
using namespace std;
 
class Pet {
  string pname;
public:
  Pet(const string& petName) : pname(petName) {}
  virtual string name() const { return pname; }
  virtual string speak() const { return ""; }
};
 
class Dog : public Pet {
  string name;
public:
  Dog(const string& petName) : Pet(petName) {}
  // Funzione virtuale nuova nella classe Dog:
  virtual string sit() const {
    return Pet::name() + " sits";
  }
  string speak() const { // Override
    return Pet::name() + " says 'Bark!'";
  }
};
 
int main() {
  Pet* p[] = {new Pet("generic"),new Dog("bob")};
  cout << "p[0]->speak() = "
       << p[0]->speak() << endl;
  cout << "p[1]->speak() = "
       << p[1]->speak() << endl;
//! cout << "p[1]->sit() = "
//!      << p[1]->sit() << endl; // Non legale
} ///:~

La classe Pet contiene due funzioni virtuali: speak( ) e name( ). Dog aggiunge una terza funzione virtuale chiamata sit(), così come effettua l’override di speak(). Un diagramma può aiutare a visualizzare cos’è successo. Ecco la VTABLE creata dal compilatore per Pet e Dog:

Si noti che il compilatore mappa la locazione con l’indirizzo di speak() esattamente nello stesso posto sia nella VTABLE di Dog che in quella di Pet. In maniera simile, se la classe Pug viene ereditata da Dog, la sua versione di sit() viene messa nella sua VTABLE esattamente nello stesso posto che occupa in Dog. Questo perché (come è stato visto nell’esempio in linguaggio assembler) il compilatore genera un codice che utilizza un semplice offset numerico all’interno della VTABLE per selezionare la funzione virtuale. Indipendentemente dal particolare sottotipo a cui appartiene l’oggetto, la sua VTABLE è fatta sempre allo stesso modo e le chiamate alle funzioni virtuali vengono fatte sempre in maniera uguale.

In questo caso, comunque, il compilatore lavora solo con il puntatore ad un oggetto appartenente alla classe base. La classe base ha solo le funzioni speak() e name(), e queste sono gli unici metodi che il compilatore permette di utilizzare. Come potrebbe, infatti, sapere che si sta lavorando con un oggetto di tipo Dog, se ha a disposizione solo il puntatore ad un oggetto della classe base? Questo puntatore potrebbe puntare ad un altro tipo di oggetto, che non ha la funzione sit(). Ci potrebbero essere o meno gli indirizzi di altre funzioni nella VTABLE, ma in ogni caso, avendo fatto una chiamata virtuale a questa VTABLE non si vuole utilizzare questa possibilità. Per questo il compilatore fa il suo lavoro impedendo che si possano fare chiamate a funzioni virtuali che esistono solo nelle classi derivate.

Ci sono alcuni casi poco comuni in cui si vuole conoscere se il puntatore sta puntando ad un particolare tipo di oggetto. Se si vuole chiamare una funzione che esiste solo in questa sottoclasse, allora si deve fare il cast del puntatore. Si può eliminare l’errore presente nel programma precedente utilizzando una chiamata di questo tipo:

  ((Dog*)p[1])->sit()

In questo caso, si deve conoscere che p[1] punta a un oggetto di tipo Dog, ma in generale questa informazione non è disponibile. Se il problema che si ha è tale da dover conoscere il tipo esatto di tutti gli oggetti, si dovrebbe ripensare alla soluzione, perché probabilmente non si stanno usando in modo corretto le funzioni virtuali. Esistono, però, delle situazioni in cui il progetto è più efficiente (oppure non si hanno alternative) se si conosce il tipo esatto di tutti gli oggetti contenuti in un generico contenitore. Questo problema va sotto il nome di identificazione del tipo a run-time (run-time type identification RTTI).

L’RTTI consiste nel fare il cast verso il basso o down-cast verso i puntatori alla classe derivata dei puntatori alla classe base (“up” e “down” sono relativi al tipico diagramma delle classi, dove la classe base è disegnata in cima). Il cast verso l’alto avviene automaticamente, senza nessuna forzatura, perché è un’operazione sicura. Il cast verso il basso, invece, non è un’operazione sicura, perché non si ha nessuna informazione a tempo di compilazione circa i tipo effettivo, quindi bisogna conoscere esattamente di che tipo è l’oggetto. Se viene fatto il cast verso un tipo sbagliato, si avranno dei problemi.

L’RTTI viene trattato più avanti in questo capitolo e il Volume 2 di questo libro ha un capitolo dedicato a questo argomento.

Object slicing

Quando si usa il polimorfismo c’è una differenza tra il passare gli indirizzi degli oggetti e passare gli oggetti per valore. Tutti gli esempi presentati, e teoricamente tutti gli esempi che si vedranno, passano gli indirizzi e non i valori. Questo perché gli indirizzi hanno tutti la stessa dimensione[58], quindi  passare l’indirizzo di un oggetto appartenente ad un tipo derivato (che tipicamente è un oggetto più grande) è la stessa cosa di passare l’indirizzo di un oggetto appartenente alla classe base (che tipicamente è un oggetto più piccolo). Come si è visto prima questo è quello che si desidera quando si usa il polimorfismo – un codice che operi sui tipi base può  operare in maniera trasparente anche su oggetti appartenenti a classi derivate.

Se si effettua l’upcast ad un oggetto invece che ad un puntatore o ad un riferimento, succede qualcosa che potrebbe sorprendere: l’oggetto viene “affettato” (“sliced”) fino a che tutto quello che rimane non è altro che il suboggetto che corrisponde al tipo destinazione dell’operazione di cast. Nell’esempio successivo si può vedere cosa succede quando un oggetto viene affettato:

//: C15:ObjectSlicing.cpp
#include <iostream>
#include <string>
using namespace std;
 
class Pet {
  string pname;
public:
  Pet(const string& name) : pname(name) {}
  virtual string name() const { return pname; }
  virtual string description() const {
    return "This is " + pname;
  }
};
 
class Dog : public Pet {
  string favoriteActivity;
public:
  Dog(const string& name, const string& activity)
    : Pet(name), favoriteActivity(activity) {}
  string description() const {
    return Pet::name() + " likes to " +
      favoriteActivity;
  }
};
 
void describe(Pet p) { // “Affetta” l’oggetto
  cout << p.description() << endl;
}
 
int main() {
  Pet p("Alfred");
  Dog d("Fluffy", "sleep");
  describe(p);
  describe(d);
} ///:~

Alla funzione describe() viene passato per valore un oggetto del tipo Pet. Viene chiamata, poi, la funzione virtuale description() per un oggetto di tipo Pet. Nel main(), ci si aspetterebbe che la prima chiamata produca “This is Alfred”, e la seconda produca “Fluffy likes sleep”. Di fatto, entrambe le chiamate utilizzano la versione della classe base di description().

In questo programma avvengono due cose. Primo, siccome describe() accetta un oggetto Pet (piuttosto che un puntatore o un riferimento), ogni chiamata a describe() provocherà l’inserimento di un oggetto delle dimensioni di Pet nello stack e la sua cancellazione dopo la chiamata. Questo significa che se un oggetto di una classe che eredita da Pet viene passato a describe(), il compilatore darà nessuna segnalazione, ma copierà solo la parte dell’oggetto corrispondente ad un oggetto Pet. Il compilatore taglierà via la parte derivata dell’oggetto, in questo modo:

è interessante vedere, ora,  cosa succede quando si chiama una funzione virtuale. Dog::description() fa uso di entrambi Pet (che continua ad esistere) e Dog, che non esiste più perché è stato tagliato via! Cosa succede quando la funzione virtuale viene chiamata?

Si è al sicuro da eventuali effetti disastrosi perché l’oggetto è stato passato per valore. Per questo motivo, il compilatore conosce il tipo preciso di oggetto visto che l’oggetto derivato è stato trasformato in maniera forzata in un oggetto delle classe base. Quando si utilizza il passaggio di un oggetto per valore, viene utilizzato il costruttore di copia per un oggetto Pet, che inizializza il VPTR con l’indirizzo della VTABLE di Pet e copia solo le parti corrispondenti all’oggetto Pet stesso. Non essendoci un costruttore di copia esplicito, è il compilatore a sintetizzarne uno. Da qualunque punto di vista, a causa dell’object slicing l’oggetto passato per valore diventa un vero è proprio oggetto Pet.

Di fatto, l’object slicing rimuove parte dell’oggetto esistente quando viene creato un nuovo oggetto, piuttosto che cambiare il significato di un indirizzo così come avviene quando si usa un puntatore o un riferimento. Per questo motivo, l’upcasting in un altro oggetto non viene utilizzato spesso; di fatto, solitamente, si cerca di tenersi alla larga e di prevenire l’uso dell’upcasting. Si noti che, in questo esempio, se description() fosse stata una funzione virtuale pura nella classe base (che è un’ipotesi ragionevole, visto che di fatto non effettua nessuna operazione nella classe base), allora il compilatore avrebbe impedito l’object slicing perché non avrebbe permesso la “creazione” di un oggetto della classe base (che è quello che succede quando si effettua l’upcast del valore). Questo potrebbe essere l’utilizzo più importante per le funzioni virtuali pure: prevenire l’object slicing generando un messaggio d’errore durante la compilazione se si cerca di utilizzarlo.

Overloading & overriding

Nel capitolo 14, si è visto che la ridefinizione di una funzione nella classe base attraverso l’overload nasconde tutte le altre versioni della funzione stessa nella classe base. Quando si lavora con funzioni virtuali, il comportamento e leggermente diverso. Si consideri una versione modificata dell’esempio NameHiding.cpp presentato nel capitolo 14:

//: C15:NameHiding2.cpp
// Restrizioni all’overload delle funzioni virtuali
#include <iostream>
#include <string>
using namespace std;
 
class Base {
public:
  virtual int f() const { 
    cout << "Base::f()\n"; 
    return 1; 
  }
  virtual void f(string) const {}
  virtual void g() const {}
};
 
class Derived1 : public Base {
public:
  void g() const {}
};
 
class Derived2 : public Base {
public:
  // Override di una funzione virtuale:
  int f() const { 
    cout << "Derived2::f()\n"; 
    return 2;
  }
};
 
class Derived3 : public Base {
public:
  // non si può cambiare il tipo restituito:
  //! void f() const{ cout << "Derived3::f()\n";}
};
 
class Derived4 : public Base {
public:
  // Lista degli argomenti cambiata:
  int f(int) const { 
    cout << "Derived4::f()\n"; 
    return 4; 
  }
};
 
int main() {
  string s("hello");
  Derived1 d1;
  int x = d1.f();
  d1.f(s);
  Derived2 d2;
  x = d2.f();
//!  d2.f(s); // la versione string è nascosta
  Derived4 d4;
  x = d4.f(1);
//!  x = d4.f(); // la versione f() è nascosta
//!  d4.f(s); // la versione string è nascosta
  Base& br = d4; // Upcast
//!  br.f(1); // Versione derivata non disponibile
  br.f(); // Versione base disponibile 
  br.f(s); // Versione base disponibile
} ///:~

La prima cosa da notare è che in Derive3, il compilatore non permette di cambiare il tipo restituito da una funzione ridefinita mediante overidde (sarebbe permesso se f() non fosse virtuale). Questa restrizione è importante perché il compilatore deve garantire la possibilità di chiamare “polimorficamente” la funzione attraverso la classe base e se la classe base suppone che f() restituisca un int, allora la versione di f() nella classe derivata deve onorare questa aspettativa altrimenti le cose non funzionano.

La regola esposta nel Capitolo 14 è ancora valida: se si effettua l’override di un metodo della classe base per cui è stato fatto l’overload, le altre versioni del metodo nella classe base non sono più accessibili, vengono nascoste. Nel main() il codice che testa Derived4 mostra che anche se la nuova versione di f() non è ottenuta attraverso l’override dell’interfaccia di una funzione virtuale esistente – entrambe le versioni di f() della classe base vengono nascoste da f(int). Ciononostante, se si effettua l’upcast di d4 in Base, solo le versioni della classe base sono disponibili (perché questo è quello che viene assicurato dalla classe base) mentre quella della classe derivata non lo è più (perché non è specificata nella classe base).

Cambiare il tipo restituito

La classe Derived3 di prima mostra come non si possa modificare il tipo restituito da una funzione virtuale con l’override. Questo vale in generale, ma c’è un caso speciale in cui si può modificare leggermente il tipo restituito. Se si sta restituendo un puntatore oppure un riferimento ad una classe base, allora la versione della funzione ottenuta tramite override può restituire un puntatore oppure in riferimento ad una classe derivata da quella restituita dalla funzione nella classe base. Per esempio:

//: C15:VariantReturn.cpp
// Restituire un puntatore o un riferimento ad un tipo
// derivato durante l’override
#include <iostream>
#include <string>
using namespace std;
 
class PetFood {
public:
  virtual string foodType() const = 0;
};
 
class Pet {
public:
  virtual string type() const = 0;
  virtual PetFood* eats() = 0;
};
 
class Bird : public Pet {
public:
  string type() const { return "Bird"; }
  class BirdFood : public PetFood {
  public:
    string foodType() const { 
      return "Bird food"; 
    }
  };
  // Upcast al tipo base:
  PetFood* eats() { return &bf; }
private:
  BirdFood bf;
};
 
class Cat : public Pet {
public:
  string type() const { return "Cat"; }
  class CatFood : public PetFood {
  public:
    string foodType() const { return "Birds"; }
  };
  // Restituisce il tipo esatto:
  CatFood* eats() { return &cf; }
private:
  CatFood cf;
};
 
int main() {
  Bird b; 
  Cat c;
  Pet* p[] = { &b, &c, };
  for(int i = 0; i < sizeof p / sizeof *p; i++)
    cout << p[i]->type() << " eats "
         << p[i]->eats()->foodType() << endl;
  // Può restituire il tipo esatto:
  Cat::CatFood* cf = c.eats();
  Bird::BirdFood* bf;
  // Non può restituire il tipo esatto:
//!  bf = b.eats();
  // Bisogna effettuare il downcast:
  bf = dynamic_cast<Bird::BirdFood*>(b.eats());
} ///:~

La funzione membro Pet::eats() restituisce un puntatore a un PetFood. In Bird, questa funzione membro viene derivata esattamente dalla classe base tramite overload, incluso il tipo restituito. Si ha che Bird::eats() effettua l’upcast di BirdFood in PetFood.

In Cat, invece, il tipo restituito da eats() è un puntatore a CatFood,un tipo derivato da PetFood. L’unica ragione per cui questa classe viene compilata è che il tipo restituito viene ereditato dal tipo restituito dalla funzione nella classe base. In questo modo il comportamento atteso viene rispettato; eats() restituisce sempre un puntatore ad un PetFood.

Se si ragiona in maniera “polimorfica”, un comportamento di questo tipo non sembra necessario. Perché non effettuare l’upcast a PetFoods* dei tipi restituiti, così come viene fatto da Bird::eats()? Tipicamente questa è una buona soluzione, alla fine del main() viene mostrata la differenza: Cat::eats() può restituire il tipo esatto di PetFood, mentre del valore restituito da Bird::eats() bisogna effettuare il downcast verso il tipo desiderato.

Quindi riuscire a restituire il tipo esatto garantisce maggiore generalità e non causa la perdita di informazioni sul tipo dovute all’upcast automatico. Spesso restituendo il tipo base generalmente si riescono a risolvere i proprio problemi, quindi questa caratteristica viene sfruttata solo in casi particolari.

funzioni virtuali & costruttori

Quando viene creato un oggetto contenente delle funzioni virtuali, il suo VPTR deve essere inizializzato in maniera tale da puntare alla VTABLE appropriata. Questo deve essere fatto prima che venga data la possibilità di chiamare una qualsiasi funzione virtuale. Come si può intuire, siccome il costruttore ha il compito di creare un oggetto, si occuperà anche di inizializzare il VPTR. Il compilatore inserisce segretamente all’inizio del costruttore del codice per l’inizializzazione del VPTR. Come descritto nel Capitolo 14, se non viene creato esplicitamente un costruttore per una classe, sarà il compilatore a sintetizzarne uno. Se la classe possiede delle funzioni virtuali, il costruttore sintetizzato comprenderà il codice appropriato per l’inizializzazione del VPTR. Tenendo conto di questo comportamento è possibile fare alcune considerazioni.

La prima riguarda l’efficienza. Il motivo per cui vengono utilizzate le funzioni inline è quello di ridurre l’overhead dovuto alle chiamate delle funzioni piccole. Se il C++ non avesse fornito le funzioni inline, si sarebbe potuto utilizzare il preprocessore per creare queste ”macro”. Il preprocessore, però, non ha il concetto di accesso o di classe, per cui non potrebbe essere utilizzato per creare funzioni membro come macro. Inoltre, con i costruttori che hanno codice nascosto inserito dal compilatore, una macro per il preprocessore non funzionerebbe.

Quando si è alla caccia di falle nell’efficienza dei programmi, bisogna tenere presente che il compilatore sta inserendo codice nascosto nel costruttore. Non solo inizializza il VPTR, ma controlla il valore di this (nel caso l’operatore new restituisca zero) e chiama i costruttori delle classi base. Preso insieme, questo codice può influire su quella che si pensava essere una chiamata ad una piccola funzione inline. In particolare, la dimensione del costruttore potrebbe rendere nulli tutti gli sforzi fatti per ridurre l’overhead delle chiamate a funzione. Se vengono fatte molte chiamate ai costruttori inline, le dimensioni del codice possono crescere senza apportare alcun beneficio alla velocità di esecuzione.

Sicuramente non è preferibile implementare tutti i piccoli costruttori come non-inline, perché è più semplice scriverli come inline. Quando si stanno mettendo a punto le prestazioni del codice, però, bisogna ricordarsi di eliminare i costruttori inline.

Ordine delle chiamate ai costruttori

Il secondo aspetto interessante dei costruttori e delle funzioni virtuali riguarda l’ordine in cui i costruttori vengono chiamati e il modo in cui le chiamate virtuali vengono fatte all’interno dei costruttori.

Tutti i costruttori delle classi base vengono sempre chiamati dal costruttore di una classe ereditata. Questo comportamento ha senso perché il costruttore ha un compito speciale: fare in modo che l’oggetto venga costruito in maniera corretta. Una classe derivata può inizializzare correttamente i propri elementi. Per questo motivo è essenziale che tutti i costruttori vengano chiamati; altrimenti l’oggetto nel suo insieme non verrebbe costruito correttamente. Questo è il motivo per cui il compilatore forza una chiamata al costruttore per ogni parte della classe derivata. Se nella lista dei costruttori non viene specificato esplicitamente quale costruttore della classe base utilizzare, il compilatore chiamerà il costruttore di default. Se non c’è alcun costruttore di default, il compilatore darà errore.

L’ordine delle chiamate ai costruttori è importante. Quando si eredita, si conosce tutto della classe base e si può accedere a qualunque suo membro public o protected. Questo significa che si dovrebbe poter ritenere validi tutti i membri della classe base quando si è nella classe derivata. In una funzione membro normale, la costruzione dell’oggetto è già stata effettuata, quindi tutti i membri di tutte le parti dell’oggetto sono stati costruiti. All’interno del costruttore, invece, si dovrebbe essere in grado di ritenere che tutti i membri che vengono utilizzati sono stati costruiti. L’unico modo per garantire questo è di chiamare per primo il costruttore della classe base. In questo modo quando ci si trova nel costruttore della classe derivata,  tutti i membri della classe base a cui si può accedere sono stati inizializzati. “Poter ritenere tutti i membri validi” all’interno del costruttore è la ragione per cui, quando è possibile, bisognerebbe inizializzare tutti gli oggetti membro (vale a dire tutti gli oggetti contenuti nella classe) utilizzando la lista dei costruttori. Seguendo questa abitudine, si può ritenere che tutti i membri della classe base e tutti gli oggetti membro del oggetto corrente sono stati inizializzati.

Comportamento delle funzioni virtuali all’interno dei costruttori

L’ordine con cui vengono chiamati i costruttori porta con se un dilemma interessante. Cosa succede se all’interno di un costruttore si chiama una funzione virtuale? All’interno di una funzione membro ordinaria si può immaginare cosa succede – la chiamata virtuale viene risolta a runtime perché l’oggetto non sa se appartiene alla classe in cui la funzione membro si trova, oppure a qualche classe derivata da questa. Per consistenza, si è portati a pensare lo stesso avvenga anche all’interno dei costruttori.

Invece, non è questo quello che accade. Se si chiama una funzione virtuale all’interno di un costruttore, solo la versione locale della funzione viene utilizzata. Questo perché il meccanismo virtuale non funziona all’interno del costruttore.

Ci sono due motivi per cui questo comportamento ha senso. Concettualmente, il compito del costruttore è quello di creare un oggetto in modo tale che esso esista (che non è un’operazione molto ordinaria da fare). All’interno di ogni costruttore, l’oggetto può essere costruito solo parzialmente -  si è sicuri che solo gli oggetti appartenenti alla classe base  sono stati inizializzati, ma non si conoscono quali sono le classi ereditate da quella attuale. Una chiamata ad una funzione virtuale, invece, percorre “verso il basso” o “verso l’alto” la gerarchia delle eredità. Chiama una funzione in una classe derivata. Se si potesse fare questo anche all’interno di un costruttore, si potrebbe chiamare una funzione che opera su membri che non sono stati ancora inizializzati, una ricetta sicura per un disastro.

Il secondo motivo è di tipo meccanico. Quando un costruttore viene chiamato, una delle prime cose che fa è l’inizializzazione del proprio VPTR. Inoltre, può solo conoscere che lui è del tipo “corrente” – cioè del tipo per cui il costruttore è stato scritto. Il  codice del costruttore ignora completamente se questo oggetto è o meno la base di un’altra classe. Quando il compilatore genera il codice per questo costruttore, genera il codice per il costruttore di questa classe, non di una classe base o di una classe derivata da questa (perché una classe non conosce chi erediterà da lei). Quindi il VPTR che userà sarà per la VTABLE di questa classe. Il VPTR rimane inizializzato con il valore di questa VTABLE per il resto della vita dell’oggetto solo se questa è l’ultima chiamata ad un costruttore. Se dopo viene chiamato un costruttore derivato, questo setterà il VPTR con l’indirizzo della sua VTABLE, e così via, fino all’ultimo costruttore chiamato. Lo stato del VPTR è determinato dal costruttore che viene chiamato per ultimo. Questo è un altro motivo per cui  i costruttori vengono chiamati in ordine da quello base a l’ultimo derivato.

Ma mentre tutta questa serie di chiamate a costruttore ha luogo, ogni costruttore ha settato il VPTR all’indirizzo della propria VTABLE. Se si usa il meccanismo virtuale per le chiamate a funzione, verrà effettuata la chiamata attraverso la propria VTABLE, non con la VTABLE dell’ultimo oggetto derivato (come succederebbe dopo  le chiamate a tutti i costruttori). In più, molti compilatori riconoscono che una chiamata a funzione virtuale viene fatta da un costruttore ed effettuano l’early binding perché sanno che il late –binding produrrà una chiamata alla funzione locale. In entrambi i casi, non si otterranno i risultati  aspettati effettuando una chiamata a funzione virtuale all’interno di un costruttore.

Distruttori e distruttori virtuali

Non si può utilizzare le parola chiave virtual con i costruttori, ma i distruttori possono, e spesso devono, essere virtuali.

Il costruttore ha lo speciale compito di mettere assieme un oggetto pezzo per pezzo, chiamando per primo il costruttore base e dopo i costruttori derivati nell’ordine in cui sono ereditati (durante questa sequenza di operazioni può anche chiamare i costruttori di oggetti membri). In modo simile, il distruttore ha un compito speciale: deve smontare un oggetto che potrebbe appartenere ad una gerarchia di classi. Per far questo, il compilatore genera del codice che chiama tutti i distruttori, ma in ordine inverso rispetto a quanto viene fatto per i costruttori. Quindi, il distruttore parte dalla classe più derivata e lavora all’indietro fino alla classe base. Questo è un comportamento sicuro e desiderabile perché il distruttore corrente può sempre ritenere che i membri della classe base sono vivi e attivi. Se è necessario chiamare una funzione membro della classe base all’interno del distruttore, questa è un’operazione sicura. Quindi, il distruttore, può effettuare la propria pulizia, poi chiama il distruttore successivo nella gerarchia, che effettuerà la propria pulizia, ecc. Ogni distruttore conosce che la propria classe è derivata da altre classi , ma non quelle che sono derivate dalla propria classe.

Si dovrebbe ricordare che i costruttori e i distruttori sono gli unici posti dove questa chiamata gerarchica deve essere fatta (cioè la gerarchia appropriata viene generata automaticamente dal compilatore). In tutti le altre funzioni, solo quella funzione viene chiamata (e non le versioni della classe base), che sia virtuale o meno. L’unico modo affinché le versioni della classe base della stessa funzione vengano chiamate nelle funzioni ordinarie (virtuali o meno) è quello di chiamare esplicitamente la funzione.

Normalmente, l’azione del distruttore è adeguata. Ma cosa succede se si vuole utilizzare un oggetto attraverso un puntatore alla sua classe base (cioè, si vuole utilizzare l’oggetto attraverso la sua interfaccia generica)? Questo tipo di operazione è uno degli obiettivi principali della programmazione orientata agli oggetti. Il problema si incontra quando si vuole fare il delete del puntatore di questo tipo per un oggetto che è stato creato nell’heap con un new. Se è un puntatore alla classe base, il compilatore sa solo che deve chiamare la versione del distruttore della classe base durante un delete. Ricorda niente? Questo è lo stesso problema per risolvere il quale sono state create le funzioni virtuali. Fortunatamente, le funzioni virtuali funzionano per i distruttori come per tutte le altre funzioni tranne i costruttori.

//: C15:VirtualDestructors.cpp
// Differenze di comportamento tra distruttori virtuali e non 
// virtuali
#include <iostream>
using namespace std;
 
class Base1 {
public:
  ~Base1() { cout << "~Base1()\n"; }
};
 
class Derived1 : public Base1 {
public:
  ~Derived1() { cout << "~Derived1()\n"; }
};
 
class Base2 {
public:
  virtual ~Base2() { cout << "~Base2()\n"; }
};
 
class Derived2 : public Base2 {
public:
  ~Derived2() { cout << "~Derived2()\n"; }
};
 
int main() {
  Base1* bp = new Derived1; // Upcast
  delete bp;
  Base2* b2p = new Derived2; // Upcast
  delete b2p;
} ///:~

Quando si lancia il programma, si vede che delete bp chiama solo il distruttore della classe base, mentre delete b2p chiama il distruttore della classe derivate seguito dal distruttore della classe base, che è il comportamento desiderato. Dimenticare di dichiarare il distruttore virtual è un errore insidioso perché spesso non influisce direttamente sul comportamento del proprio programma, ma può tranquillamente introdurre una perdita di memoria. Inoltre, il fatto che qualche distruzione avviene può nascondere per molto tempo il problema.

Anche se il distruttore, come il costruttore, è una funzione “che fa eccezione”, è possibile per il distruttore essere virtuale perché l’oggetto conosce già di che tipo è (così non é durante la costruzione). Una volta che un oggetto è stato costruito, il suo VPTR è inizializzato, quindi si possono fare chiamate virtuali a funzione.

Distruttori virtuali puri

Nonostante i distruttori virtuali puri siano legali nel C++ Standard, c’è un ulteriore obbligo quando vengono utilizzati: bisogna dotare di un corpo i distruttori virtuali puri. Questo può sembrare non intuitivo; come può un funzione essere “pura” se  ha bisogno di un corpo? Se si ricorda, però, che i costruttori e i distruttori rappresentato operazioni speciali, questo obbligo acquista maggior significato, specialmente se si ricorda che tutti i distruttori vengono sempre chiamati in una gerarchia di classi. Se si potesse lasciare vuota la definizione di un distruttore virtuale puro, quale corpo di funzione verrebbe chiamato durante la distruzione? Quindi, è assolutamente necessario che il compilatore ed il linker forzino l’esistenza di un corpo per un distruttore virtuale puro.

Se è puro, ma ha il corpo, qual è il suo significato? L’unica differenza che si può vedere tra un distruttore virtuale puro e non-puro è che il primo costringe la classe ad essere astratta, quindi non si può creare un’istanza della classe base (questo accadrebbe anche se una qualsiasi altra funzione nella classe base fosse virtuale pura).

Le cose sono un poco più confuse, inoltre, quando si eredita una classe da una che contiene un distruttore virtuale puro. Differentemente da qualsiasi altra funzione virtuale pura, non è obbligatorio fornire una definizione di un distruttore virtuale puro nella classe derivata. Il fatto che il listato seguente venga compilato e linkato ne è una prova:

//: C15:UnAbstract.cpp
// Distruttori virtuali puri
// sembra comportarsi in modo strano
 
class AbstractBase {
public:
  virtual ~AbstractBase() = 0;
};
 
AbstractBase::~AbstractBase() {}
 
class Derived : public AbstractBase {};
// L’ovveride del distruttore non è necessario?
 
int main() { Derived d; } ///:~

Normalmente, un funzione virtuale pura in una classe base dovrebbe comportare che anche la classe derivata sia astratta se non ne venga data una definizione (e anche delle altre funzioni virtuali pure). Ma in questo caso, sembra che questo non sia vero. Bisogna ricordare che il compilatore automaticamente crea una definizione del distruttore per ogni classe se questo non viene fatto esplicitamente. Questo è quello che succede in questo caso – viene fatto l’override del distruttore della classe base e questa definizione viene utilizzata dal compilatore in maniera tale che Derived non sia più astratta.

Quanto visto porta a formulare una domanda interessante: Qual’è la caratteristica di un distruttore virtuale puro? Non è quella di una normale funzione virtuale pura, per cui bisogna fornire un corpo. In una classe derivata, non si è obbligati a fornire una definizione perché il compilatore sintetizza il distruttore per noi. Quindi qual è la differenza tra un distruttore virtuale normale e uno virtuale puro?

L’unica differenza si ha quando si ha una classe che possiede una sola funzione virtuale pura: il distruttore. In questo caso, l’unico effetto della purezza del distruttore è quello di prevenire che la classe base venga istanziata. Se ci fossero altre funzioni virtuali pure, queste provvederebbero ad evitare che la classe base venga istanziata, ma se no ce ne sono altre, allora questo verrà fatto dal distruttore virtuale puro. Quindi, mentre l’aggiunta di un distruttore virtuale è essenziale, che sia puro o no non è così importante.

Quando si esegue l’esempio seguente, si può vedere che il corpo della funzione virtuale pura viene chiamato dopo la versione della classe derivata, come avviene per ogni altro distruttore:

//: C15:PureVirtualDestructors.cpp
// Distruttori virtuali puri
// richiede un body per la funzione
#include <iostream>
using namespace std;
 
class Pet {
public:
  virtual ~Pet() = 0;
};
 
Pet::~Pet() {
  cout << "~Pet()" << endl;
}
 
class Dog : public Pet {
public:
  ~Dog() {
    cout << "~Dog()" << endl;
  }
};
 
int main() {
  Pet* p = new Dog; // Upcast
  delete p; // Chiamata al distruttore virtuale
} ///:~

Come linea guida, ogni volta che si ha una funzione virtuale in una classe, si dovrebbe immediatamente aggiungere un distruttore virtuale (anche se non fa nulla). In questo modo, ci si assicura contro ogni sorpresa futura.

Chiamate virtuali all’interno dei distruttori

C’è qualcosa che succede durante la distruzione che non ci si aspetta. Se ci si trova all’interno di una funzione membro ordinaria e si chiama una funzione virtuale, questa funzione viene chiamata utilizzando il meccanismo del late-binding. Questo non è vero con i distruttori, virtuali o non virtuali. All’interno di un distruttore, solo la versione “locale” di una funzione membro viene chiamata; il meccanismo virtuale viene ignorato.

//: C15:VirtualsInDestructors.cpp
// Chiamate virtuali all’interno dei distruttori
#include <iostream>
using namespace std;
 
class Base {
public:
  virtual ~Base() { 
    cout << "Base1()\n"; 
    f(); 
  }
  virtual void f() { cout << "Base::f()\n"; }
};
 
class Derived : public Base {
public:
  ~Derived() { cout << "~Derived()\n"; }
  void f() { cout << "Derived::f()\n"; }
};
 
int main() {
  Base* bp = new Derived; // Upcast
  delete bp;
} ///:~

Durante la chiamata al distruttore, Derived::f() non viene chiamata, anche se f() è virtuale.

Che cosa succede? Supponiamo che il meccanismo virtuale venga utilizzato all’interno del distruttore. Allora sarebbe possibile per una chiamata virtuale arrivare ad una funzione “più esterna” (appartenente ad una classe derivata) nella gerarchia delle classi rispetto al distruttore corrente. Ma i costruttori vengono chiamati dal “più esterno al più interno” (dal distruttore dell’ultima classe derivata fino al distruttore della classe base), quindi la chiamata a funzione considerata dovrebbe dipendere da una parte di un oggetto che è stato già distrutto! Questo non succede, perché il compilatore risolve la chiamata a tempo di compilazione e chiama solo la versione “locale” della funzione. Si noti che lo stesso vale per il costruttore (come è stato descritto precedentemente), ma nel caso del costruttore l’informazione sul tipo non era disponibile, mentre nel distruttore l’informazione (vale a dire il VPTR) c’è, ma non è affidabile.

Creare una gerarchia object-based

Un argomento ricorrente in questo libro durante l’illustrazione delle classi Stack e Stash è stato “il problema dell’appartenenza”. Il “padrone” fa riferimento a chi o cosa è responsabile della chiamata a delete per oggetti che sono stati creati dinamicamente (utilizzando new). Il problema quando si usano contenitori è che questi devono essere abbastanza flessibili da poter contenere diversi tipi di oggetti. Per fare questo, i contenitori posseggono dei puntatori a void e quindi non conoscono il tipo di oggetto che dovranno contenere. Quando si cancella un puntatore a void non si chiama il distruttore, quindi il contenitore non può essere responsabile della pulizia degli oggetti che contiene.

Una soluzione è stata presentata nell’esempio C14:InheritStack.cpp, dove Stack veniva ereditato da una nuova classe che accettava e produceva solo puntatori a string. Dato che sapeva di poter contenere solo puntatori ad oggetti string, poteva cancellarli in maniera propria. Questa era una bella soluzione, ma bisognava ereditare una nuova classe contenitore per ogni tipo che si voleva inserire nel contenitore. (Anche se ora questo sembra tedioso, funzionerà abbastanza bene nel Capitolo 16, quando verranno introdotti i template).

Il problema è che si vuole che il contenitore contenga più di un solo tipo e non si vogliono usare puntatori a void. Un’altra soluzione è quella di usare il polimorfismo forzando tutti gli oggetti che si metteranno nel contenitore ad essere ereditati dalla stessa classe base. In questo modo, il contenitore conterrà oggetti della classe base e si potranno chiamare funzioni virtuali – in particolare, si potranno chiamare distruttori virtuali per risolvere il problema dell’appartenenza.

Questa soluzione usa quella che è conosciuta come gerarchia singly-root o gerarchia object –based (perché la classe root della gerarchia viene chiamata solitamente “Object”). Ci sono molti altri vantaggi nell’utilizzare una gerarchia singly-root; infatti, tutti gli altri linguaggi object-oriented oltre al C++ forzano l’uso di questa gerarchia – quando si crea una classe, automaticamente questa viene ereditata direttamente o indirettamente da una classe base comune, una classe che è stata stabilita dai creatori del linguaggio. Nel C++, si è pensato che l’uso forzato di una classe base comune avrebbe causato troppo overhead, quindi non è stato fatto. Comunque, si può decidere di utilizzare una classe base comune nei propri progetti e questo argomento verrà esaminato a fondo nel Volume 2 di questo libro.

Per risolvere il problema dell’appartenenza, si può creare un classe Object estremamente semplice come classe base, che contiene solo un distruttore virtuale. Stack può, allora, contenere classi ereditate da Object:

//: C15:OStack.h
// Uso di una gerarchia singly-rooted
#ifndef OSTACK_H
#define OSTACK_H
 
class Object {
public:
  virtual ~Object() = 0;
};
 
// Definizione richiesta:
inline Object::~Object() {}
 
class Stack {
  struct Link {
    Object* data;
    Link* next;
    Link(Object* dat, Link* nxt) : 
      data(dat), next(nxt) {}
  }* head;
public:
  Stack() : head(0) {}
  ~Stack(){ 
    while(head)
      delete pop();
  }
  void push(Object* dat) {
    head = new Link(dat, head);
  }
  Object* peek() const { 
    return head ? head->data : 0;
  }
  Object* pop() {
    if(head == 0) return 0;
    Object* result = head->data;
    Link* oldHead = head;
    head = head->next;
    delete oldHead;
    return result;
  }
};
#endif // OSTACK_H ///:~

Per semplificare le cose e tenere tutto nel file di header, la definizione (richiesta) per il distruttore virtuale puro viene fatta inline nel file di header stesso e anche pop() (che potrebbe essere considerata troppo grande per essere inline) viene definita come inline.

Gli oggetti Link ora contengono dei puntatori a Object invece che a void e Stack accetterà e restituirà solo puntatori a Object. Ora Stack è più flessibile, potrà contenere molti tipi diversi ma distruggerà anche ogni oggetto che verrà lasciato in Stack. Il nuovo limite (che verrà rimosso definitivamente quando verranno utilizzati i template per risolvere il problema nel Capitolo 16) è che tutto ciò che viene messo nello Stack deve essere ereditato da Object. Questo può andare bene se si sta realizzando la classe da inserire partendo dal progetto, ma cosa succede se la classe da mettere in Stack esiste già, come string? In questo caso, la nuova classe dove essere sia un string che un Object, ciò significa che deve essere ereditata da entrambe le classi. Questa operazione viene chiamata ereditarietà multipla ed è l’argomento di un intero capitolo nel Volume 2 di questo libro (scaricabile all’indirizzo www.BruceEckel.com). Quando il lettore leggerà questo capitolo, vedrà che l’ereditarietà multipla è legata alla complessità ed è una caratteristica da utilizzare limitatamente. In questa situazione, comunque, tutto è abbastanza semplice che non ci si vuole inoltrare in nessun vicolo buio legato all’ereditarietà multipla:

//: C15:OStackTest.cpp
//{T} OStackTest.cpp
#include "OStack.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
// Usa l’ereditarietà multipla. Vogliamo 
// entrambi string e Object:
class MyString: public string, public Object {
public:
  ~MyString() {
    cout << "deleting string: " << *this << endl;
  }
  MyString(string s) : string(s) {}
};
 
int main(int argc, char* argv[]) {
  requireArgs(argc, 1); // Il file name è un argomento
  ifstream in(argv[1]);
  assure(in, argv[1]);
  Stack textlines;
  string line;
  // Legge il file e memorizza le righe nello stack:
  while(getline(in, line))
    textlines.push(new MyString(line));
  // Estrae alcune righe dallo stack:
  MyString* s;
  for(int i = 0; i < 10; i++) {
    if((s=(MyString*)textlines.pop())==0) break;
    cout << *s << endl;
    delete s; 
  }
  cout << "Letting the destructor do the rest:"
    << endl;
} ///:~

Anche se questa versione del programma di test per Stack è molto simile alla precedente, si noterà che solo 10 elementi vengono estratti dallo stack, il che significa che probabilmente degli oggetti rimangono dentro. Dato che Stack sa di essere un contenitore di Object, il distruttore può effettuare una cancellazione corretta, e si vedrà che questa è l’uscita del programma, dato che gli oggetti MyString  stampano un messaggio quando vengono distrutti.

Creare contenitori che contengano Object è un approccio ragionevole – se si ha una gerarchia singly-root (forzata dal linguaggio oppure dalla specifica che ogni classe erediti da Object). In questo caso, è garantito che tutto è un Object e quindi non è così difficile utilizzare i contenitori. In C++, comunque, non ci si può aspettare questo per ogni classe, quindi sarà d’obbligo arrivare all’ereditarietà multipla se si utilizza questo approccio. Si vedrà nel Capitolo 16 che i template risolvono il problema in modo più semplice è più elegante.

Overload degli operatori

Si possono avere operatori virtual così come si fa con le altre funzioni membro. Implementare operatori virtual spesso porta confusione, perché si dovrebbe operare con due oggetti, entrambi di tipo non noto. Solitamente questa è la situazione con componenti matematici (per i quali  spesso si vuole l’overload degli operatori). Per esempio, si consideri un sistema che lavora con matrici, vettori e valori scalari, tutti e tre derivati dalla classe Math:

//: C15:OperatorPolymorphism.cpp
// Polimorfismo con overload di operatori
#include <iostream>
using namespace std;
 
class Matrix;
class Scalar;
class Vector;
 
class Math {
public:
  virtual Math& operator*(Math& rv) = 0;
  virtual Math& multiply(Matrix*) = 0;
  virtual Math& multiply(Scalar*) = 0;
  virtual Math& multiply(Vector*) = 0;
  virtual ~Math() {}
};
 
class Matrix : public Math {
public:
  Math& operator*(Math& rv) {
    return rv.multiply(this); // 2nd dispatch
  }
  Math& multiply(Matrix*) {
    cout << "Matrix * Matrix" << endl;
    return *this;
  }
  Math& multiply(Scalar*) {
    cout << "Scalar * Matrix" << endl;
    return *this;
  }
  Math& multiply(Vector*) {
    cout << "Vector * Matrix" << endl;
    return *this;
  }
};
 
class Scalar : public Math  {
public:
  Math& operator*(Math& rv) {
    return rv.multiply(this); // 2nd dispatch
  }
  Math& multiply(Matrix*) {
    cout << "Matrix * Scalar" << endl;
    return *this;
  }
  Math& multiply(Scalar*) {
    cout << "Scalar * Scalar" << endl;
    return *this;
  }
  Math& multiply(Vector*) {
    cout << "Vector * Scalar" << endl;
    return *this;
  }
};
 
class Vector : public Math  {
public:
  Math& operator*(Math& rv) {
    return rv.multiply(this); // 2nd dispatch
  }
  Math& multiply(Matrix*) {
    cout << "Matrix * Vector" << endl;
    return *this;
  }
  Math& multiply(Scalar*) {
    cout << "Scalar * Vector" << endl;
    return *this;
  }
  Math& multiply(Vector*) {
    cout << "Vector * Vector" << endl;
    return *this;
  }
};
 
int main() {
  Matrix m; Vector v; Scalar s;
  Math* math[] = { &m, &v, &s };
  for(int i = 0; i < 3; i++)
    for(int j = 0; j < 3; j++) {
      Math& m1 = *math[i];
      Math& m2 = *math[j];
      m1 * m2;
    }
} ///:~

Per semplicità, è stato fatto l’overload solo dell’operatore operator*. L’obiettivo è quello di riuscire a moltiplicare qualsiasi coppia di oggetti Math e produrre il risultato desiderato – e si noti che moltiplicare una matrice per un vettore è un’operazione molto diversa dal moltiplicare un vettore per una matrice.

Il problema è che, nel main(), l’espressione m1*m2 contiene due riferimenti a Math ottenuti mediante upcast, e quindi due oggetti di tipo sconosciuto. Una funzione virtuale è capace solo di effettuare un singolo compito – cioè determinare il tipo di uno degli oggetti sconosciuti. Per determinare entrambi i tipi la tecnica chiamata multiple dispatching è utilizzata in questo esempio, dove quella che sembra la chiamata ad un’unica funzione virtuale diviene la chiamata ad una seconda funzione virtuale. Quando la seconda chiamata viene fatta, vengono determinati i tipi di entrambi gli oggetti e si può fare l’operazione appropriata. Non risulta subito chiaro, ma se si ferma un po’ l’attenzione sull’esempio si dovrebbe iniziare a capirne il senso. Questo argomento viene trattato in maniera più approfondita nel capitolo Progettazione di Pattern del Volume 2, che può essere scaricato all’indirizzo www.BruceEckel.com.

Downcast

Come si  può intuire, così come esiste una cosa chiamata upcast – che si muove verso l’alto in una gerarchia di classi – ci dovrebbe essere anche il downcast per muoversi verso il basso nella gerarchia. Ma l’upcast è semplice perché quando ci si muove verso l’alto in una gerarchia le classi convergono sempre a classi più generali. Quando si effettua l’upcast è sempre chiaro da quali genitori si è derivati (tipicamente uno, tranne nel caso dell’ereditarietà multipla), ma quando si effettua il downcast solitamente ci sono diverse possibilità per effettuare il cast. In particolare, un Circle è un tipo di Shape (questo è un upcast), ma se si prova a fare il downcast di un Shape potrebbe essere un Circle, un Square, un Triangle, ecc. Quindi si pone il problema di un downcast sicuro. (Ma una cosa più importante da chiedersi è perché si sta utilizzando il downcast invece di utilizzare semplicemente il polimorfismo per ottenere automaticamente il tipo corretto. I motivi per cui si preferisce non utilizzare il downcast sono trattati nel Volume 2 di questo libro.)

Il C++ mette a disposizione un cast esplicito (introdotto nel Capitolo 3) chiamato dynamic_cast che è un’operazione di downcast sicura. Quando si usa il dynamic_cast per provare a fare il downcast verso un tipo particolare, il valore restituito sarà un puntatore al tipo desiderato solo se il cast è consentito e viene effettuato con successo, altrimenti viene restituito zero per indicare che il tipo non era corretto. Ecco un piccolo esempio:

//: C15:DynamicCast.cpp
#include <iostream>
using namespace std;
 
class Pet { public: virtual ~Pet(){}};
class Dog : public Pet {};
class Cat : public Pet {};
 
int main() {
  Pet* b = new Cat; // Upcast
  // Prova a fare il cast a Dog*:
  Dog* d1 = dynamic_cast<Dog*>(b);
  // Prova a fare il cast a Cat*:
  Cat* d2 = dynamic_cast<Cat*>(b);
  cout << "d1 = " << (long)d1 << endl;
  cout << "d2 = " << (long)d2 << endl;
} ///:~

Quando si usa dynamic_cast, si deve lavorare con una vera gerarchia polimorfica – con funzioni virtuali – perché dymanic_cast utilizza informazioni memorizzate nella VTABLE per determinare il tipo corrente. In questo caso, la classe base contiene un distruttore virtuale e questo è sufficiente. Nel main(), viene fatto l’upcast di un puntatore a Cat verso uno a Pet e poi viene fatto un dowcast su entrambi i puntatori a Dog e a Cat. Entrambi i puntatori vengono stampati e si potrà vedere quando verrà lanciato il programma che il downcast sbagliato restituirà uno zero. Quindi, ogni volta che si effettua il downcast bisogna controllare il risultato dell’operazione per assicurarsi che sia diverso da zero. Inoltre, non si dovrebbe assumere che il puntatore sarà esattamente lo stesso, perché a volte si hanno degli aggiustamenti sul puntatore durante l’upcast ed il downcast (in particolare nel caso di ereditarietà multipla).

Un dynamic_cast richiede un piccolo extra overhead quando viene eseguito; ma se vengono fatti molti dynamic_cast (nel qual caso bisognerebbe porsi domande serie sul progetto) questo potrebbe diventare un punto critico per le prestazioni. In alcuni casi si vuole conoscere qualcosa di speciale durante il downcast che permetta di essere sicuri sul tipo con cui si sta lavorando, in questo caso l’extra overhead dovuto al dynamic_cast non è necessario, ed, al suo posto, si può utilizzare un static_cast. Ecco come dovrebbe funzionare:

//: C15:StaticHierarchyNavigation.cpp
// Navigare gerarchie di classi con static_cast
#include <iostream>
#include <typeinfo>
using namespace std;
 
class Shape { public: virtual ~Shape() {}; };
class Circle : public Shape {};
class Square : public Shape {};
class Other {};
 
int main() {
  Circle c;
  Shape* s = &c; // Upcast: normale ed OK
  // Più esplicito ma non necessario:
  s = static_cast<Shape*>(&c);
  // (Dato che l’upcast e un’operazione comune e sicura,
  // il the cast diventa superfluo)
  Circle* cp = 0;
  Square* sp = 0;
  // La navigazione statica di una gerarchia di classi
  // richiede informazioni extra sul tipo:
  if(typeid(s) == typeid(cp)) // C++ RTTI
    cp = static_cast<Circle*>(s);
  if(typeid(s) == typeid(sp))
    sp = static_cast<Square*>(s);
  if(cp != 0)
    cout << "It's a circle!" << endl;
  if(sp != 0)
    cout << "It's a square!" << endl;
  // La navigazione statica è SOLO un trucco per l’efficienza;
  // Il dynamic_cast è sempre più sicuro. Comunque:
  // Other* op = static_cast<Other*>(s);
  // da un messaggio d’errore che può essere utile, mentre
  Other* op2 = (Other*)s;
  // non lo fa
} ///:~

In questo programma, è stata usata una nuova caratteristica che verrà descritta completamente nel Volume 2 di questo libro, dove un intero capitolo sarà dedicato all’argomento: Identificazione del tipo a run-time con il C++ (RTTI). L’RTTI permette di scoprire l’informazione sul tipo andata persa dopo un upcast. Il dynamic_cast di fatto è una forma di RTTI. In questo caso la parola chiave typeid (dichiarata nel file di header <typeinfo>) viene utilizzata per identificare il tipo dei puntatori. Si può notare che il tipo di un puntatore a Shape ottenuto mediante upcast viene successivamente confrontato con un puntatore a Circle e a Square per scoprire il tipo reale. L’RTTI è molto di più che l’utilizzo di typeid e si può anche immaginare che sarebbe abbastanza facile implementare il proprio sistema di informazioni sul tipo utilizzando le funzioni virtuali.

Un oggetto Circle viene creato e viene fatto l’upcast del suo indirizzo ad un puntantore a Shape; la seconda versione di quest’operazione mostra come si può utilizzare static_cast per essere più espliciti rispetto l’upcast. Comunque, dato che un upcast è sempre un’operazione sicura e comune, l’autore considera un upcast esplicito poco chiaro e non necessario

L’RTTI viene utilizzato per determinare il tipo, poi static_cast effettua il downcast. Si noti, però, che in questo esempio il procedimento è esattamente lo stesso utilizzato con dynamic_cast, cioè il programmatore deve effettuare dei test e scoprire il cast corretto. Tipicamente si vorrebbe una situazione che sia più deterministica rispetto a quella presentata nell’esempio precedente quando si utilizza static_cast piuttosto che dynamic_cast (e, di nuovo, si dovrà riesaminare il progetto con attenzione prima di utilizzare un dynamic cast).

Se una gerarchia di classi non possiede funzioni virtuali (che è una scelta discutibile) oppure se si hanno altre informazioni che permettono un downcast sicuro, è un po’ più veloce effettuare il downcast staticamente piuttosto che con il dynamic_cast. In aggiunta, l’uso di static_cast non permette di effettuare il cast al di fuori della gerarchia, come permetterebbe il cast tradizionale, quindi è più sicuro. Comunque, navigare staticamente le gerarchie di classi è sempre rischioso e si dovrebbe utilizzare il dynamic_cast se non ci si vuole trovare in situazioni strane.

Riepilogo

Polimorfismo – implementato nel C++ attraverso l’uso delle funzioni virtuali – significa “forme diverse.” Nella programmazione object-oriented, si ha la stessa faccia (l’interfaccia comune nella classe base) e modi differenti di utilizzarla: le differenti versioni delle funzioni virtuali.

Si è visto, in questo capitolo, che è impossibile capire, o anche creare, un esempio di polimorfismo senza utilizzare l’astrazione dei dati e l’ereditarietà. Il polimorfismo è una caratteristica che non può essere vista in maniera isolata (come, per esempio, le istruzioni const o switch), ma lavora assieme ad altre proprietà del linguaggio, come una parte del “grande quadro” delle relazioni tra classi. Le persone vengono spesso confuse dalle altre caratteristiche del C++ non orientate agli oggetti, come l’overload e gli argomenti di default, che a volte vengono presentati come caratteristiche orientate agli oggetti. Non bisogna farsi ingannare; se non è late-binding non è polimorfismo.

Per utilizzare il polimorfismo – e, quindi, le tecniche di programmazione object-oriented – nei propri programmi bisogna espandere il concetto di programmazione per includere non solo i membri e i messaggi di una sola classe, ma anche gli aspetti comuni e le relazioni tra le varie classi. Sebbene questo richiede uno sforzo significativo, è una battaglia che vale la pena affrontare, perché si otterranno una maggiore velocità nello sviluppo del programma, una migliore organizzazione del codice, programmi estendibili ed una manutenzione del codice semplificata.

Il polimorfismo completa la serie di caratteristiche object-oriented del linguaggio, ma ci sono altre due caratteristiche importanti nel C++: i template (che verranno introdotti nel Capitolo 16 e verranno trattati in maggior dettaglio nel Volume 2) e la gestione delle eccezioni (che verrà trattata nel Volume 2). Queste caratteristiche forniscono un aumento della potenza di programmazione come ognuna delle caratteristiche object-oriented: astrazione dei dati, ereditarietà e polimorfismo.

Esercizi

Le soluzioni agli esercizi presentati possono essere trovate nel documento elettronico The Thinking in C++ Annotated Solution Guide, disponibile con un piccolo contributo all’indirizzo www.BruceEckel .com.

  1. Creare una semplice gerarchia di “forme”: una classe base chiamata Shape e delle classi derivate chiamate Circle, Square e Triangle. Nella classe base, creare una funzione virtuale chiamata draw() ed effettuare l’override di questa nelle classi derivate. Creare nell’heap un array di puntatori ad oggetti di tipo Shape (effettuare quindi l’upcast dei puntatori) e si chiami draw() attraverso i puntatori alla classe base, per verificare il funzionamento delle funzioni virtuali. Se il proprio debugger lo permette, eseguire in single-step il codice.
  2. Modificare l’Esercizio 1 in maniera tale da rendere draw() un funzione virtuale pura. Si provi a creare un oggetto di tipo Shape. Si provi a chiamare la funzione virtuale pura all’interno del costruttore e si osservi cosa accade. Lasciandola sempre virtuale pura, si dia a draw() una definizione.
  3. Estendere l’Esercizio 2,  creare una funzione che accetti come argomento un oggetto Shape passato per valore e provare ad effettuare l’upcast di un oggetto derivato per passarlo alla funzione come argomento. Osservare cosa accade. Correggere la funzione utilizzando un riferimento all’oggetto Shape.
  4. Modificare C14:Combined.cpp in modo tale che f( ) sia virtuale nella classe base. Cambiare il main( ) in maniera tale da effettuare un upcast e una chiamata ad una funzione virtuale.
  5. Modificare Instrument3.cpp aggiungendo una funzione virtual prepare( ). Chiamare prepare( ) all’interno di tune( ).
  6. Creare una gerarchia di Rodent: Mouse, Gerbil, Hamster, etc. Nella classe base inserire metodi che sono comuni a tutti i roditori e si ridefiniscano nelle classi derivate in maniera tale da specificare i vari comportamenti  a seconda del tipo specifico di roditore. Creare un array di puntatori a Rodent, riempirlo con diversi tipi di roditori e chiamare i metodi della classe base per vedere cosa accade.
  7. Modificare l’Esercizio 6 in modo da utilizzare un vector<Rodent*> al posto dell’array di puntatori. Assicurarsi che la memoria venga pulita in maniera corretta.
  8. Partendo dalla precedente gerarchia Rodent, ereditare BlueHamster da Hamster (ebbene si, esiste; ne avevo uno quando ero bambino), fare l’override dei metodi della classe base e dimostrare che il codice che chiama i metodi della classe base non va modificato per poter gestire anche questo tipo nuovo.
  9. Partendo dalla precedente gerarchia Rodent, aggiungere un distruttore non virtuale, creare un oggetto della classe Hamster utilizzando new, effettuare l’upcast del puntatore ad un Rodent* e cancellare con delete il puntatore per mostrare che non vengono chiamati tutti i distruttori nella gerarchia. Cambiare il distruttore in uno virtuale e dimostrare che il comportamento che si ottiene è corretto.
  10. Partendo dalla precedente gerarchia Rodent, modificare Rodent in maniera da renderla una classe astratta pura.
  11. Creare un sistema di controllo del traffico aereo con la classe base Aircraft e varie classi derivate. Creare una classe Tower con un vector<Aircraft*> che invii i messaggi appropriati ai vari aerei che sono sotto il suo controllo.
  12. Creare un modello di serra ereditando vari tipi di Plant e costruire meccanismi interni alla sera che si prendano cura delle piante.
  13. In Early.cpp, rendere Pet una classe astratta pura.
  14. In AddingVirtuals.cpp, rendere tutte le funzioni membro di Pet virtuali pure, ma dare una definizione per name( ). Correggere Dog se necessario, utilizzando la definizione della classe base di name( ).
  15. Scrivere un piccolo programma per mostrare la differenza tra la chiamata di una funzione virtuale all’interno di una normale funzione membro e la chiamata di una funzione virtuale all’interno di un costruttore. Il programma dovrebbe provare che le due chiamate producono risultati differenti.
  16. Modificare VirtualsInDestructors.cpp ereditando una classe da Derived ed effettuando l’override di f( ) e del distruttore. Nel main( ), creare ed effettuare l’upcast di un oggetto del nuovo tipo e dopo effettuarne il delete.
  17. Prendere l’Esercizio 16 e aggiungere delle chiamate ad f() in ogni distruttore. Spiegare cosa succede.
  18. Creare una classe che ha un dato membro e una classe derivata che aggiunge a questo un altro dato membro. Scrivere una funzione non membro alla quale viene passato per valore un oggetto della classe base e stampi la dimensione dell’oggetto utilizzando sizeof. Nel main( ) creare un oggetto della classe derivata, stampare la sua dimensione e poi chiamare la funzione costruita. Spiegare cosa succede.
  19. Creare un semplice esempio di chiamata ad una funzione virtuale e generare il codice assembler. Individuare il codice assembler della chiamata virtuale e commentare il codice.
  20. Scrivere una classe con una funzione virtuale e una funzione non virtuale. Ereditare una nuova classe, istanziare un oggetto di questa classe nuova, fare l’upcast ad un puntatore alla classe base. Usare la funzione clock( ) contenuta in <ctime> (bisognerà cercarla tre le proprie librerie locali C) per misurare la differenza tra una chiamata virtuale e una non virtuale. Sarà necessario effettuare più chiamate per ogni funzione  all’interno di un ciclo per poter apprezzare la differenza.
  21. Modificare C14:Order.cpp aggiungendo una funzione virtuale nella classe base della macro CLASS (che effettua alcune stampe) e aggiungendo un distruttore virtuale. Istanziare oggetti di varie sottoclassi ed effettuarne l’upcast  alla classe base. Verificare che il comportamento virtuale funzioni e che la costruzione e la distruzione avvengano correttamente.
  22. Scrivere una classe con tre funzioni virtuali con overload. Ereditare una classe nuova da queste e effettuare l’override di una di queste funzioni. Creare un oggetto della classe derivata. È possibile chiamare tutte le funzioni della classe base attraverso l’oggetto derivato? Effettuare l’upcast verso un oggetto base dell’indirizzo dell’oggetto istanziato. È possibile chiamare tutte le tre funzioni attraverso l’oggetto base? Rimuovere gli override nella classe derivata. È possibile, ora, chiamare tutte le funzioni della classe base attraverso un oggetto della classe derivata?
  23. Modificare VariantReturn.cpp in maniera da ottenere lo stesso comportamento sia operando su puntatori che su riferimenti.
  24. In Early.cpp, come è possibile determinare quando il compilatore effettua le chiamate utilizzando l’earl o il late binding? Determinare cosa avviene per il proprio compilatore.
  25. Creare una classe base che contenga una funzione clone( ) che restituisca un puntatore ad una copia dell’oggetto corrente. Derivare due sottoclassi che effettuino l’override di clone( ) in maniera da restituire le copie dei loro tipi specifici. Nel main( ), creare e effettuare l’upcast degli oggetti dei due tipi derivati, dopo chiamare clone() per ognuno di loro e verificare che le copie clonate siano del tipo corretto. Fare delle prove con la funzione clone( ) in maniera tale che restituisca un tipo base e dopo si provi a restituire il tipo derivato esatto. Sapreste dire quando è necessario quest’ultimo approccio?
  26. Modificare OStackTest.cpp creando la propria classe, successivamente si erediti insieme a Object (ereditarietà multipla) per creare un oggetto che possa essere inserito in Stack. Testare la propria classe nel main( ).
  27. Aggiungere un tipo chiamato Tensor in OperatorPolymorphism.cpp.
  28. (Intermedio) Creare una classe base X senza dati membri e senza costruttore, ma con una funzione virtuale. Creare una classe Y che erediti da X, ma senza un costruttore esplicito. Generare il codice assembler e esaminarlo per determinare se un costruttore è stato creato e chiamato per X e se sì, cosa fa il codice. Spiegare cosa si è scoperto. X non ha costruttori di default, quindi perché il compilatore non segnala errori?
  29. (Intermedio) Modificare l’Esercizio 28 scrivendo i costruttori per entrambe le classi in maniera tale che ogni costruttore chiami una funzione virtuale. Generare il codice assembler. Determinare in che punto il VPTR viene assegnato ad ogni costruttore. Il proprio compilatore utilizza il meccanismo virtuale all’interno del costruttore? Stabilire perché viene chiamata la versione locale della funzione.
  30. (Avanzato) Se le chiamate di una funzione ad un oggetto passato per valore non fossero ottenute mediante early-bound, una chiamata virtuale potrebbe accedere a componenti che non esistono. È possibile? Scrivere del codice per forzare la chiamata virtuale e osservare se questo causa un crash. Per spiegare il comportamento, esaminare cosa succede quando si passa un oggetto per valore.
  31. (Avanzato) Trovare esattamente quanto tempo è necessario per effettuare una chiamata virtuale cercando sul manuale del linguaggio assembler del proprio processore o su un altro manuale tecnico il numero di impulsi di clock richiesti per una semplice chiamata e confrontarlo con il numero richiesto per una chiamata a funzione virtuale.
  32. Determinare la dimensione (utilizzando sizeof) del VPTR nel proprio sistema. Dopo si ereditino due classi che contengono funzioni virtuali (ereditarietà multipla). Si ottengono uno o due VPTR nella classe derivata?
  33. Creare una classe con dati membri e funzioni virtuali. Scrivere una funzione che guardi nella memoria  occupata da un oggetto della classe e ne stampi i vari pezzi. Per fare questo bisognerà provare in maniera iterativa e scoprire dove viene posizionato il VPTR all’interno dell’oggetto.
  34. Presumere che le funzioni virtuali non esistano e modificare Instrument4.cpp in maniera da utilizzare dynamic_cast per fare l’equivalente delle chiamate virtuali. Spiegare perché questa non è una buona idea.
  35. Modificare StaticHierarchyNavigation.cpp in modo da utilizzare, al posto del C++ RTTI, un proprio RTTI creato con una funzione virtuale chiamata whatAmI() nella classe base e un tipo enum { Circles, Squares };.
  36. Iniziare con PointerToMemberOperator.cpp dal Chapter 12 e mostrare che il polimorfismo continua a funzionare con puntatori-a-membro, anche se viene fatto l’overload di operator->*.

[54] I compilatori possono implementare il meccanismo delle funzioni virtuali in qualsiasi modo, ma l’approccio qui descritto è quello adottato dalla maggior parte dei compilatori.

[55] Alcuni compilatori potrebbero utilizzare le stesse dimensioni mostrate in questo libro, ma questo sarà sempre più raro con il passare del tempo.

[56] Smalltalk, Java e Python, per esempio, usano questo approccio con grande successo.

[57] Ai Bell Labs, dove il C++ è stato inventato, ci sono molti programmatori C. Renderli tutti più produttivi, anche di poco, fa risparmiare all’azienda diversi milioni.

[58] In verità, non su tutte le macchine i puntatori hanno le stesse dimensioni . Nel contesto che si sta trattando, però, si può ritenere questa affermazione valida.

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]

Aggiornato al : 12/11/2002