[ Suggerimenti
] [ Soluzioni
degli Esercizi] [ Volume
2 ] [ Newsletter
Gratuita ]
[ Seminari
] [ Seminari
su CD ROM ] [ Consulenza]
[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]
trad. italiana e adattamento a cura di Gianmaria De Tommasi
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”.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
È 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.
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.
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.
È 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.)
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
[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 ]