Costruttori
L'inizializzazione di un oggetto potrebbe essere eseguita dichiarando un metodo ad
hoc (diciamo Set(/* ... */)) da utilizzare eventualmente anche per l'assegnamento. Tuttavia
assegnamento e inizializzazione sono operazioni semanticamente molto diverse e l'uso di una tecnica simile non va
bene a nessuno dei due scopi in quanto si tratta di operazioni eseguite oltretutto in contesti diversi e a cui
sono delegate responsabilita` diverse. Per adesso vedremo come viene inizializzata una istanza di classe, piu` avanti
vedremo un modo elegante di eseguire l'assegnamento utilizzando il meccanismo di overloading
degli operatori.
Un primo motivo per cui un metodo tipo
|
class Complex {
public:
void Set(float re, float im);
/* ... */
private:
float Re;
float Im;
};
void Complex::Set(float re, float im) {
Re = re;
Im = im;
}
|
non puo` andare bene e` che il programmatore che usa la classe potrebbe dimenticare di chiamare tale metodo prima di
cominciare ad utilizzare l'oggetto appena dichiarato. L'inizializzazione e` una operazione troppo importante e non ci
si puo` concedere il lusso di dimenticarsene (un tempo la NASA perse un satellite per una simile dimenticanza!).
Si potrebbe pensare di scrivere qualcosa del tipo:
|
class Complex {
public:
/* ... */
private:
float Re = 6; // Errore!
float Im = 7; // Errore!
};
|
ma il compilatore rifiutera` di accettare tale codice. Il motivo e` semplice, stiamo definendo un tipo e non una variabile
(o una costante) e non e` possibile inizializzare i membri di una classe (o di una struttura) in quel modo...
E poi in questo modo ogni istanza della classe sarebbe sempre inizializzata con valori prefissati, e la situazione sarebbe
sostanzialmente quella di prima.
Il metodo corretto e` quello di fornire un costruttore che il compilatore possa utilizzare quando una istanza della
classe viene creata, in modo che tale istanza sia sin dall'inizio in uno stato consistente. Un costruttore altro non e` che
un metodo il cui nome e` lo stesso di quello della classe. Un costruttore puo` avere un qualsiasi numero di parametri,
ma non restituisce mai alcun tipo (neanche void); il suo scopo e` quello di inizializzare le istanze della
classe:
|
Class Complex {
public:
Complex(float a, float b) { // costruttore!
Re = a;
Im = b;
}
/* altre funzioni membro */
private:
float Re; // Parte reale
float Im; // Parte immaginaria
};
|
In questo modo possiamo eseguire dichiarazione e inizializzazione di un oggetto Complex in un colpo
solo:
|
Complex C(3.5, 4.2);
|
La definizione appena vista introduce un oggetto C di tipo Complex che viene inizializzato
chiamando il costruttore con gli argomenti specificati tra le parentesi. Si noti che il costruttore non
viene invocato come un qualsiasi metodo (il nome del costruttore non e` cioe` esplicitamente mensionato, esso e` implicito nel tipo dell'istanza); un sistema alternativo di eseguire l'inizializzazione sarebbe:
|
Complex C = Complex(3.5, 4.2);
|
ma e` poco efficiente perche` quello che si fa e` creare un oggetto Complex temporaneo e poi copiarlo in
C (sara` chiaro in seguito il perche` della cosa), il primo metodo invece fa tutto in un colpo solo.
Un costruttore puo` eseguire compiti semplici come quelli dell'esempio, tuttavia non e` raro che una classe necessiti di
costruttori molto complessi, specie se alcuni membri sono dei puntatori; in questi casi un costruttore puo` eseguire
operazioni quali allocazione di memoria o accessi a unita` a disco se si lavora con oggetti persistenti.
In alcuni casi, alcune operazioni possono richiedere la certezza assoluta che tutti o parte dei campi dell'oggetto che si
vuole creare siano subito inizializzati prima ancora che incominci l'esecuzione del corpo del costruttore; la soluzione in
questi casi prende il nome di lista di inizializzazione.
La lista di inizializzazione e` una caratteristica propria dei costruttori e appare sempre tra la lista di argomenti del
costruttore e il suo corpo:
|
class Complex {
public:
Complex(float, float);
/* ... */
private:
float Re;
float Im;
};
Complex::Complex(float a, float b) : Re(a), Im(b) { }
|
L'ultima riga dell'esempio implementa il costruttore della classe Complex; si tratta esattamente dello stesso
costruttore visto prima, la differenza sta tutta nel modo in cui sono inizializzati i membri dato: la notazione
Attributo(< Espressione >) indica al compilatore che Attributo deve memorizzare il valore
fornito da Espressione; Espressione puo` essere anche qualcosa di complesso come la chiamata ad
una funzione.
Nel caso appena visto l'importanza della lista di inizializzazione puo` non essere evidente, lo sara` di piu` quando
parleremo di oggetti composti e di ereditarieta`.
Una classe puo` possedere piu` costruttori, cioe` i costruttori possono essere overloaded, in modo da offrire diversi modi
per inizializzare una istanza; in particolare alcuni costruttori assumono un significato speciale:
- il costruttore di default ClassName::ClassName();
- il costruttore di copia ClassName::ClassName(ClassName& X);
- altri costruttori con un solo argomento;
Il costruttore di default e` particolare, in quanto e` quello che il compilatore chiama quando il programmatore non utilizza
esplicitamente un costruttore nella dichiarazione di un oggetto:
|
#include < iostream >
using namespace std;
class Trace {
public:
Trace() {
cout << "costruttore di default" << endl;
}
Trace(int a, int b) : M1(a), M2(b) {
cout << "costruttore Trace(int, int)" << endl;
}
private:
int M1, M2;
};
int main(int, char* []) {
cout << "definizione di B... ";
Trace B(1, 5); // Trace(int, int) chiamato!
cout << "definizione di C... ";
Trace C; // costruttore di default chiamato!
return 0;
}
|
Eseguendo tale codice si ottiene l'output:
|
definizione di B... costruttore Trace(int, int)
definizione di C... costruttore di default
|
Ma l'importanza del costruttore di default e` dovuta soprattutto al fatto che se il programmatore della classe non definisce
alcun costruttore, automaticamente il compilatore ne fornisce uno (che pero` non da` garanzie sul contenuto dei membri dato
dell'oggetto). Se non si desidera il costruttore di default fornito dal compilatore, occorre definirne
esplicitamente uno (anche se non di default).
Il costruttore di copia invece viene invocato quando un nuovo oggetto va inizializzato in base al contenuto di un altro;
modifichamo la classe Trace in modo da aggiungere il seguente costruttore di copia:
|
Trace::Trace(Trace& x) : M1(x.M1), M2(x.M2) {
cout << "costruttore di copia" << endl;
}
|
e aggiungiamo il seguente codice a main():
|
cout << "definizione di D... ";
Trace D = B;
|
Cio` che viene visualizzato ora, e` che per D viene chiamato il costruttore di copia.
Se il programmatore non definisce un costruttore di copia, ci pensa il compilatore. In questo caso il costruttore fornito dal
compilatore esegue una copia bit a bit (non e` proprio cosi`, ma avremo modo di vederlo in seguito) degli attributi; in
generale questo e` sufficiente, ma quando una classe contiene puntatori e` necessario definirlo esplicitamente onde
evitare problemi di condivisione di aree di memoria.
I principianti tendono spesso a confondere l'inizializzazione con l'assegnamento; benche` sintatticamente le due operazioni
siano simili, in realta` esiste una profonda differenza semantica: l'inizializzazione viene compiuta una volta sola,
quando l'oggetto viene creato; un assegnamento invece si esegue su un oggetto precedentemente creato.
Per comprendere la differenza facciamo un breve salto in avanti.
Il C++ consente di eseguire l'overloading degli operatori, tra cui quello per l'assegnamento; come nel caso caso del
costruttore di copia, anche per l'operatore di assegnamento vale il discorso fatto nel caso che tale
operatore non venga definito esplicitamente, anche in questo caso il compilatore fornisce automaticamente un operatore di
assegnamento. Il costruttore di copia viene utilizzato quando si dichiara un nuovo oggetto e si inizializza il suo
valore con quello di un altro; l'operatore di assegnamento invece viene invocato successivamente in tutte le operazioni che
assegnano all'oggetto dichiarato un altro oggetto.
Vediamo un esempio:
|
#include < iostream >
using namespace std;
class Trace {
public:
Trace(Trace& x) : M1(x.M1), M2(x.M2) {
cout << "costruttore di copia" << endl;
}
Trace(int a, int b) : M1{a), M2(b) {
cout << "costruttore Trace(int, int)" << endl;
}
Trace & operator=(const Trace& x) {
cout << "operatore =" << endl;
M1 = x.M1;
M2 = x.M2;
return *this;
}
private:
int M1, M2;
};
int main(int, chra* []) {
cout << "definizione di A... " << endl;
Trace A(1,2);
cout << "definizione di B... " << endl;
Trace B(2,4);
cout << "definizione di C... " << endl;
Trace C = A;
cout << "assegnamento a C... " << endl;
C = B;
return 0;
}
|
Eseguendo questo codice si ottiene il seguente output:
|
definizione di A... costruttore Trace(int, int)
definizione di B... costruttore Trace(int, int)
definizione di C... costruttore di copia
assegnamento a C... operatore =
|
Restano da esaminare i costruttori che prendono un solo argomento.
Essi sono a tutti gli effetti dei veri e propri operatori di conversione di tipo(vedi appendice A)
che convertono il loro argomento in una istanza della classe. Ecco una classe che fornisce diversi operatori di
conversione:
|
class MyClass {
public:
MyClass(int);
MyClass(long double);
MyClass(Complex);
/* ... */
private:
/* ... */
};
int main(int, char* []) {
MyClass A(1);
MyClass B = 5.5;
MyClass D = (MyClass) 7;
MyClass C = Complex(2.4, 1.0);
return 0;
}
|
Le prime tre dichiarazioni sono concettualmente identiche, in tutti e tre i casi convertiamo un valore di un tipo in quello
di un altro; il fatto che l'operazione sia eseguita per inizializzare degli oggetti non modifica in alcun modo il significato
dell'operazione stessa.
Solo l'untima dichiarazione puo` apparentemente sembrare diversa, in pratica e` comunque la stessa cosa: si crea un oggetto
di tipo Complex e poi lo si converte (implicitamente) al tipo MyClass, infine viene chiamato il
costruttore di copia per inizializzare C.
Per finire, ecco un confronto tra costruttori e metodi che riassume quanto detto:
|
Costruttori
|
Metodi
|
Tipo restituito
|
nessuno
|
qualsiasi
|
Nome
|
quello della classe
|
qualsiasi
|
Parametri
|
nessuna limitazione
|
nessuna limitazione
|
Lista di
inizializzazione
|
si
|
no
|
Overloading
|
si
|
si
|
Altre differenze e similitudini verranno esaminate nel seguito.
Distruttori
Poiche` ogni oggetto ha una propria durata
(lifetime) e` necessario disporre anche di un metodo che permetta una
corretta distruzione dell'oggetto stesso, un distruttore.
Un distruttore e` un metodo che non riceve parametri, non ritorna alcun
tipo (neanche void) ed ha lo stesso nome della classe preceduto da
una ~ (tilde):
|
class Trace {
public:
/* ... */
~Trace() {
cout << "distruttore ~Trace()" << endl;
}
private:
/* ... */
};
|
Il compito del distruttore e` quello di assicurarsi della corretta
deallocazione delle risorse e se non ne viene esplicitamente definito uno,
il compilatore genera per ogni classe un distruttore di default che chiama
alla fine della lifetime di una variabile:
|
void MyFunc() {
TVar A;
/* ... */
} // qui viene invocato automaticamente
// il distruttore per A
|
Si noti che nell'esempio non c'e` alcuna chiamata esplicita al distruttore,
e` il compilatore che lo chiama alla fine del blocco applicativo (le
istruzioni racchiuse tra { } ) in cui la variabile e` stata
dichiarata (alla fine del programma per variabili globali e statiche).
Poiche` il distruttore fornito dal compilatore non tiene conto di aree di
memoria allocate tramite membri puntatore, e` sempre necessario definirlo
esplicitamente ogni qual volta esistono membri puntatori; come mostra il
seguente esempio:
|
#include < iostream >
using namespace std;
class Trace {
public:
/* ... */
Trace(long double);
~Trace();
private:
long double * ldPtr;
};
Trace::Trace(long double a) {
cout << "costruttore chiamato... " << endl;
ldPtr = new long double(a);
}
Trace::~Trace() {
cout << "distruttore chiamato... " << endl;
delete ldPtr;
}
|
In tutti gli altri casi, spesso il distruttore di default e` piu` che
sufficiente e non occorre scriverlo.
Solitamente il distruttore e` chiamato implicitamente dal compilatore
quando un oggetto termina il suo ciclo di vita, oppure quando un oggetto
allocato con new viene deallocato con delete:
|
void func() {
Trace A(5.5); // chiamata costruttore
Trace* Ptr=new Trace(4.2); // chiamata costruttore
/* ... */
delete Ptr; // chiamata al
// distruttore
} // chiamata al
// distruttore per A
|
In alcuni rari casi puo` tuttavia essere necessario una chiamata esplicita,
in questi casi pero` il compilatore puo` non tenerne traccia (in generale
un compilatore non e` in grado di ricordare se il distruttore per una certa
variabile e` stato chiamato) e quindi bisogna prendere precauzioni onde
evitare che il compilatore, richiamando il costruttore alla fine della
lifetime dell'oggetto, generi codice errato.
Facciamo un esempio:
|
void Example() {
TVar B(10);
/* ... */
if (Cond) B.~TVar();
} // Possibile errore!
|
Si genera un errore poiche`, se Cond e` vera, e` il programma
a distruggere esplicitamente B, e la chiamata al distruttore
fatta dal compilatore e` illecita. Una soluzione al problema consiste
nell'uso di un ulteriore blocco applicativo e di un puntatore per allocare
nello heap la variabile:
|
void Example() {
TVar* TVarPtr = new TVar(10);
{
/* ... */
if (Cond) {
delete TVarPtr;
TVarPtr = 0;
}
/* ... */
}
if (TVarPtr) delete TVarPtr;
}
|
L'uso del puntatore permette di capire se la variabile e` stata
deallocata (nel qual caso il puntatore viene posto a 0) oppure no
(puntatore non nullo).
Comunque si tenga presente che i casi in cui si deve ricorrere ad una
tecnica simile sono rari e spesso (ma non sempre) denotano un frammento di
codice scritto male (quello in cui si vuole chiamare il distruttore) oppure
una cattiva ingegnerizzazione della classe cui appartiene la variabile
Si noti che poiche` un distruttore non possiede argomenti, non e` possibile
eseguirne l'overloading; ogni classe cioe` possiede sempre e solo un unico
distruttore.
Pagina precedente - Pagina successiva
C++, una panoramica sul linguaggio - seconda edizione © Copyright 1996-1999, Paolo Marotta
|