Funzioni virtuali
Il meccanismo dell'ereditarieta` e` stato gia`
di per se una grande innovazione nel mondo della programmazione, tuttavia
le sorprese non si esauriscono qui. Esiste un'altra caratteristica tipica
dei linquaggi a oggetti (C++ incluso) che ha valso loro il soprannome di
"Linguaggi degli attori": la possibilita` di avere oggetti capaci
di "recitare" di volta in volta il ruolo piu` appropriato,
ma andiamo con ordine.
L'ereditarieta` pone nuove regole circa la compatibilita` dei tipi, in
particolare se Ptr e` un puntatore di tipo T,
allora Ptr puo` puntare non solo a istanze di tipo
T ma anche a istanze di classi derivate da T
(sia tramite ereditarieta` semplice che multipla). Se Td e`
una classe derivata (anche indirettamente) da T, istruzioni
del tipo
|
T* Ptr = 0; // Puntatore nullo
/* ... */
Ptr = new Td;
|
sono assolutamente lecite e il compilatore non segnala errori o
warning. Cio` consente ad esempio la realizzazione di una lista per
contenere tutta una serie di istanze di una gerarchia di classi, magari per
poter eseguire un loop su di essa e inviare a tutti gli oggetti della lista
uno stesso messaggio.
Pensate ad esempio ad un programma di disegno che memorizza gli oggetti
disegnati mantenendoli in una lista, ogni oggetto sa come disegnarsi e se
e` necessario ridisegnare tutto il disegno basta scorrere la lista inviando
ad ogni oggetto il messaggio di Paint.
Purtroppo la cosa cosi` com'e` non puo` funzionare poiche` le funzioni sono
linkate staticamente dal linker. Anche se tutte le classi della gerarchia
possiedono un metodo Paint(), noi sappiamo solo che
Ptr punta ad un oggetto di tipo T o
T-derivato, non conoscendo l'esatto tipo una
chiamata a Ptr->Paint() non puo` che essere risolta chiamando
Ptr->T::Paint() (che non fara` in generale cio` che
vorremmo). Il compilatore non puo` infatti rischiare di chiamare il metodo
di una classe derivata, poiche` questo potrebbe tentare di accedere a
membri che non fanno parte dell'effettivo tipo dell'oggetto (causando
inconsistenze o un crash del sistema), chiamando il metodo della classe
T al piu` il programma non fara` la cosa giusta, ma non
mettera` in pericolo la sicurezza e l'affidabilita` del sistema (perche` un
oggetto derivato possiede tutti i membri della classe base).
Si potrebbe risolvere il problema inserendo in ogni classe della gerarchia
un campo che stia ad indicare l'effettivo tipo dell'istanza:
|
enum TypeId { T-Type, Td-Type };
class T {
public:
TypeId Type;
/* ... */
private:
/* ... */
};
class Td : public T {
/* ... */
};
|
e risolvere il problema con una istruzione switch:
|
switch (Ptr->Type) {
case T-Type : Ptr->T::Paint();
break;
case Td-Type : Ptr->Td::Paint();
break;
default : /* errore */
};
|
Una soluzione di questo tipo funziona ma e` macchinosa, allunga il lavoro e
una dimenticanza puo` costare cara, e soprattutto ogni volta che si
modifica la gerarchia di classi bisogna modificare anche il codice che la
usa.
La soluzione migliore e` invece quella di far in modo che il corretto tipo
dell'oggetto puntato sia automaticamente determinato al momento della
chiamata della funzione e rinviando il linking di tale funzione a
run-time.
Per fare cio` bisogna dichiarare la funzione membro virtual:
|
class T {
public:
/* ... */
virtual void Paint();
private:
/* ... */
};
|
La definizione del metodo procede poi nel solito modo:
|
void T::Paint() { // non bisogna mettere virtual
/* ... */
}
|
I metodi virtuali vengono ereditati allo stesso modo di quelli non
virtual, possono anch'essi essere sottoposti a overloading ed essere
ridefiniti, non c'e` alcuna differenza eccetto che una loro invocazione non
viene risolta se non a run-time.
Quando una classe possiede un metodo virtuale, il compilatore associa alla
classe (non all'istanza) una tabella (VTABLE) che contiene per ogni
metodo virtuale l'indirizzo alla corrispondente funzione, ogni istanza di
quella classe conterra` poi al suo interno un puntatore (VPTR) alla
VTABLE; una chiamata ad una funzione membro virtuale (e solo alle
funzioni virtuali) viene risolta con del codice che accede alla
VTABLE corrispondente al tipo dell'istanza tramite il puntatore contenuto nell'istanza stessa, ottenuta la VTABLE invocare il metodo corretto e` semplice.
Le funzioni virtuali hanno il grande vantaggio di consentire l'aggiunta di
nuove classi alla gerarchia e di renderle immediatamente e correttamente
utilizzabili dal vostro programma senza doverne modificare il codice
(ovviamente il programma dovra` comunque essere modificato in modo che
possa istanziare le nuove classi, ma il codice che gestisce che lavorava
sul generico supertipo non avra` bisogno di modifiche), il late binding
fara` in modo che siano chiamate sempre le funzioni corrette senza che il
vostro programma debba curarsi dell'effettivo tipo dell'istanza che sta
manipolando.
L'invocazione di un metodo virtuale e` piu` costosa di quella per una
funzione membro ordinaria, tuttavia il compilatore puo` evitare tale
overhead risolvendo a compile-time tutte quelle situazioni in cui il tipo
e` effettivamente noto. Ad esempio:
|
Td Obj1;
T* Ptr = 0;
/* ... */
Obj1.Paint(); // Chiamata risolvibile staticamente
Ptr->Paint(); // Questa invece no
|
La prima chiamata al metodo Paint() puo` essere risolta in
fase di compilazione perche` il tipo di Obj1 e` sicuramente
Td, nel secondo caso invece non possiamo saperlo (anche se un
compilatore intelligente potrebbe cercare di restringere le possibilita` e,
in caso di certezza assoluta, risolvere staticamente la chiamata). Se poi
volete avere il massimo controllo, potete costringere il compilatore ad una
"soluzione statica" utilizzando il risolutore di scope:
|
Td Obj1;
T* Ptr = 0;
/* ... */
Obj1.Td::Paint(); // Chiamata risolta staticamente
Ptr->Td::Paint(); // ora anche questa.
|
Adesso sia nel primo che nel secondo caso, il metodo invocato e`
Td::Paint(). Fate attenzione pero` ad utilizzare questa
possibilita` con i puntatori (come nell'ultimo caso), se per caso il tipo
corretto dell'istanza puntata non corrisponde, potreste avere delle brutte
sorprese.
Il meccanismo delle funzioni virtuali e` alla base del polimorfismo:
poiche` l'oggetto puntato da un puntatore puo` appartenere a tutta una
gerarchia di tipi, possiamo considerare l'istanza puntata come un qualcosa
che puo` assumere piu` forme (tipi) e comportarsi sempre nel modo migliore
"recitando" di volta in volta il ruolo corretto (da qui il soprannome di
"Linguaggi degli attori"), in realta` pero` un'istanza non puo` cambiare
tipo, e solo il puntatore che ha la possibilita` di puntare a tutta
una gerarchia di classi.
Limiti e regole del polimorfismo
Esistono limitazioni e regole connesse
all'uso delle funzioni virtuali legate piu` o meno direttamente al
funzionamento del polimorfismo; vediamole in dettaglio.
Una funzione membro virtuale non puo` essere dichiarata static: i
metodi virtuali sono fortemente legati alle istanze, mentre i metodi
statici sono legati alle classi.
Se una classe base dichiara un metodo virtual, tale metodo
restera` sempre virtual in tutte le classi derivate anche quando la
classe derivata lo ridefinisce senza dichiararlo esplicitamente
virtual. Scrivere cioe`
|
class Base {
public:
virtual void Foo();
};
class Derived: public Base {
public:
void Foo();
};
|
e` lo stesso che scrivere
|
class Base {
public:
virtual void Foo();
};
class Derived: public Base {
public:
virtual void Foo();
};
|
Entrambe le forme sono lecite, possiamo cioe` omettere virtual
all'interno delle classi derivate quando vogliamo ridefinire un metodo
ereditato.
E` possibile che una classe derivata ridefinisca virtual un membro
che non lo era nella classe base. In questo caso il comportamento del
compilatore puo` sembrare strano; vediamo un esempio:
|
#include < iostream >
using std::cout;
class Base {
public:
void Foo() {
cout << "Base::Foo()" << endl;
}
};
class Derived1: public Base{
public:
virtual void Foo(){
cout << "Derived1::Foo()" << endl;
}
};
class Derived2: public Derived1 {
public:
virtual void Foo(){
cout << "Derived2::Foo()" << endl;
}
};
int main(int, char* []) {
Base* BasePtr = new Derived1;
Derived* DerivedPtr = new Derived2;
BasePtr -> Foo();
DerivedPtr -> Foo();
return 0;
}
|
Eseguendo il precedente codice, otterreste questo output:
|
Base::Foo()
Derived2::Foo()
|
Essendo BasePtr un puntatore alla classe base, il compilatore
non puo` forzare il late binding perche` l'istanza puntata potrebbe essere
effettivamente di tipo Base; per non rischiare una operazione
che potrebbe mandare in crash il sistema, il compilatore adotta un
approccio conservativo e chiama sempre Base::Foo() (al
piu` il programma non fara` la cosa giusta, ma l'integrita` del resto del
sistema non sara` compromessa). Nel secondo caso, poiche`
DerivedPtr puo` puntare solo a istanze di
Derived1 o sue sottoclassi, viene eseguito regolarmente il
late binding perche` il metodo Foo() sara` sempre
virtual.
Da questo comportamento deriva un suggerimento ben preciso: se una
classe potrebbe in futuro essere derivata (se pensate cioe` che ci siano
validi motivi per specializzarla ulteriormente), e` bene che i metodi
dell'interfaccia (potenzialmente soggetti a ridefinizione) siano sempre
virtual, onde evitare potenziali errori; d'altronde se non si usa il
polimorfismo e` comunque possibile forzare la risoluzione del binding a
compile time.
Non e` possibile avere costruttori virtuali. Il meccanismo che sta alla
base del late binding richiede che l'istanza che si sta creando sia
correttamente collegata alla sua VTABLE, ma il compito di
settare correttamente il puntatore (VPTR) alla VTABLE
spetta proprio al costruttore che di conseguenza non puo` essere
dichiarato virtual. Questo problema avviamente non esiste per
i distruttori, anzi e` bene che una classe che possieda metodi virtuali
abbia anche il distruttore virtual in modo che distruggendo
oggetti polimorfi venga sempre invocato il distruttore corretto.
Metodi virtuali chiamati dentro il costruttore o nel distruttore sono
sempre risolti staticamente. Il motivo e` semplice e riconducibile
all'ordine in cui sono chiamati costruttori e distruttori.
Consideriamo il seguente esempio:
|
class Base {
public:
Base();
~Base();
virtual void Foo();
};
Base::Base() {
Foo();
}
Base::~Base() {
Foo();
}
void Base::Foo() {
/* ... */
}
class Derived: public Base {
public:
virtual void Foo();
};
void Derived::Foo() {
/* ... */
}
|
La costruzione di un oggetto Derived richiede che sia prima
eseguito il costruttore di Base. Al momento in cui
viene eseguita la chiamata a Foo() contenuta nel costruttore
della classe base il puntatore alla VTABLE puo` al piu` puntare
alla VTABLE della classe Base perche` il
costruttore della classe derivata non e` ancora stato eseguito e non e`
quindi possibile chiamare Derived::Foo(), si puo` chiamare
solo la versione locale di Foo(). La situazione e`
analoga nel caso dei distruttori, al momento in cui viene eseguito il
distruttore della classe bae, il distruttore della classe derivata e`
gia` stato eseguito ed il puntatore alla VTABLE non e` piu`
valido; di conseguenza si puo` invocare solo Base::Foo().
Il suggerimento e` quindi quello di evitare per quanto possibile la
chiamata di metodi virtuali all'interno di costruttori e distruttori,
il risultato che potreste ottenere molto probabilmente non sarebbe quello
desiderato.
Un potenziale errore legato all'uso di funzioni virtuali e` possibile
quando una funzione membro richiama un'altra funzione membro virtuale.
Consideriamo questo frammento di codice:
|
class T {
public:
virtual void Foo();
virtual void Foo2();
void DoSomething();
private:
/* ... */
};
/* implementazione di T::Foo() e T::Foo2() */
void T::DoSomething() {
/* ... */
Foo();
/* ... */
Foo2();
/* ... */
}
class Td : public T {
public:
virtual void Foo2();
void DoSomething();
private:
/* ... */
};
/* implementazione di Td::Foo2() */
void Td::DoSomething() {
/* ... */
Foo(); // attenzione chiama T::Foo()
/* ... */
Foo2();
/* ... */
}
|
Si tratta di una situazione pericolosa: la classe Td
ridefinisce un metodo non virtuale (ma poteva anche essere virtuale), ma
non uno virtuale da questo richiamato. Di per se non si tratta di un
errore, la classe derivata potrebbe non aver alcun motivo per ridefinire il
metodo ereditato, tuttavia puo` essere difficile capire cosa esattamente
faccia il metodo Td::DoSomething(), soprattutto in un caso
simile:
|
class Td2 : public Td {
public:
virtual void Foo();
private:
/* ... */
};
|
Questa nuova classe ridefinisce un metodo virtuale, ma non quello che lo
chiama, per cui in una situazione del tipo:
|
Td2* Ptr = new Td2;
/* ... */
Ptr -> DoSomething();
|
viene chiamato il metodo Td::DoSomething() ereditato, ma in
effetti questo poi chiama Td2::Foo() per via del linking
dinamico.
Il risultato in queste situazioni e` che il comportamento che una
classe puo` avere e` molto difficile da controllare e potrebbe
essere potenzialmente errato; l'errore legato a situazioni di questo
tipo e` noto in letteratura come fragile class problem e`
puo` essere causa di forti inconsistenze.
Il polimorfismo e` uno strumento estremamente potente, tuttavia richiede
una approfondita comprensione del suo funzionamente per essere utilizzato
correttamente e in modo profiquo.
Classi astratte
Il meccanismo dell'ereditarieta` e quello
del polimorfismo possono essere combinati per realizzare delle classi il
cui unico scopo e` quello di stabilire una interfaccia comune a tutta una
gerarchia di classi:
|
class TShape {
public:
virtual void Paint() = 0;
virtual void Erase() = 0;
/* ... */
};
|
Notate l'assegnamento effettuato alle funzioni virtuali, funzioni di questo
tipo vengono dette funzioni virtuali pure e l'assegnamento ha il
compito di informare il compilatore che non intendiamo definire i metodi
virtuali. Una classe che possiede funzioni virtuali pure e detta classe
astratta e non e` possibile istanziarla; essa puo` essere utilizzata
unicamente per derivare nuove classi forzandole a fornire determinati
metodi (quelli corrispondenti alle funzioni virtuali pure). Il compito di
una classe astratta e` quella di fornire una interfaccia senza esporre
dettagli implementativi. Se una classe derivata da una classe astratta non
implementa una qualche funzione virtuale pura, diviene essa stessa una
classe astratta.
Le classi astratte possono comunque possedere anche attributi e metodi
completamente definiti (costruttori e distruttore compresi) ma non possono
comunque essere istanziate, servono solo per consentire la costruzione di
una gerarchia di classi che rispetti una certa interfaccia:
|
class TShape {
public:
virtual void Paint() = 0; // ogni figura puo` essere
virtual void Erase() = 0; // disegnata e cancellata!
};
class TPoint : public TShape {
public:
TPoint(int x, int y) : X(x), Y(y) {}
private:
int X, Y; // coordinate del punto
};
void TPoint::Paint() {
/* ... */
}
void TPoint::Erase() {
/* ... */
}
|
Non e` possibile creare istanze della classe TShape, ma la
classe TPoint ridefinisce tutte le funzioni virtuali pure e
puo` essere istanziata e utilizzata dal programma; la classe
TShape e` comunque ancora utile al programma, perche`
possiamo dichiarare puntatori di tale tipo per gestire una lista di
oggetti polimorfi che possiamo utilizzare senza preoccuparci del
fatto che possano essere punti, triangoli o quant'altro.
Pagina precedente - Pagina successiva
C++, una panoramica sul linguaggio - seconda edizione © Copyright 1996-1999, Paolo Marotta
|