In C++, il costruttore ha lo scopo di garantire che un oggetto contenga sempre campi inizializzati con valori validi.
Un costruttore è una speciale funzione che viene chiamata automaticamente quando si dichiara un'istanza della classe. Questa funzione impedisce errori che possano essere conseguenza di campi non inizializzati.
Il costruttore deve avere lo stesso nome della classe. Infatti il costruttore della classe Data deve essere denominato Data().
Si osservi il costruttore della classe Data. Non solo questa funzione inizializza i campi, ma si assicura anche che questi siano validi, sostituendo il valore sbagliato con quello che più si avvicina ad un valore corretto:
Data::Data( int g, int m, int a ) {
static int lung[] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
mese = max( 1, m );
mese = min( mese, 12 );
giorno = max( 1, g );
giorno = min( giorno, lung[mese] );
anno = max( 1, a );
}
Il costruttore, in altri termini, controlla anche che i dati ricevuti come parametro, da usare per inizializzare i campi, abbiano significato.
Osservare la dichiarazione di unaData nella funzione main():
Data unaData( 3, 12, 1985 );
La sintassi per dichiarare un oggetto è simile a quella usata per dichiarare una variabile. Si specifica prima il tipo, in questo caso Data e poi il nome che si vuole assegnare all'oggetto, in questo caso unaData. A differenza della dichiarazione di una variabile, però, la dichiarazione di un oggetto contiene anche un elenco di parametri, che vengono passati al costruttore per inizializzare i campi membro dell'oggetto.
Un costruttore non restituisce valore, quindi non si deve specificare un tipo di ritorno, nemmeno void. Per lo stesso motivo, nel costruttore non può esserci un'istruzione return.
I costruttori possono essere ridefiniti, quindi una classe può avere più di un costruttore. Questa necessità si incontra quando occorre avere più di un modo per inizializzare un oggetto.
Non è obbligatorio definire un costruttore per una classe, ma è consigliabile definirlo. In assenza di una sua definizione, il compilatore ne crea uno che non fa niente e non riceve parametri.
Il distruttore
Il Distruttore è una funzione membro che ha lo stesso nome della classe e il suo nome è preceduto dal segno
tilde (~). Il distruttore di un oggetto viene chiamato automaticamente quando si passa in una regione di visibilità
in cui quell'oggetto non esiste più. Il distruttore ha lo scopo di liberare la memoria occupata dai campi
dell'oggetto.
La classe Data non ha bisogno di un distruttore. Quello che è stato dato alla classe ha solo lo scopo di mostrarne la forma.
I distruttori servono in situazioni più complesse, quando, ad esempio, si creano variabili dinamiche. Il distruttore di una classe è unico, non può essere ridefinito. Un distruttore non riceve parametri e non ha un tipo di ritorno.
Nell'esempio seguente si definisce un costruttore ed un distruttore che stampano messaggi, allo scopo di mostrare esattamente quando vengono richiamate queste funzioni:
// DEMO.CPP
#include <iostream.h>
#include <string.h>
class Demo {
public:
Demo( const char *nm );
~Demo();
private:
char name[20];
};
Demo::Demo( const char *nm ) {
strncpy( name, nm, 20 );
cout << "costruttore chiamato per " << name << endl;
}
Demo::~Demo() {
cout << "distruttore chiamato per " << name << endl;
}
void fun() {
Demo oggettoLocaleinFun( "oggetto locale in funzione" );
static Demo OggettoStatico( "OggettoStatico" );
cout << "dentro la funzione" << endl;
}
Demo oggettoGlobale( "oggettoGlobale" );
void main() {
Demo OggettoLocale( "Oggetto locale in main" );
cout " "In main prima di chiamare fun\n";
func();
cout << "In main, al ritorno da fun\n";
}
Il programma stampa le linee seguenti:
Costruttore chiamato per Oggetto Globale
Costruttore chiamato per Oggetto locale in main
In main prima di chiamare fun
Costruttore chiamato per Oggetto locale in Funzione
Costruttore chiamato per Oggetto statico
dentro la funzione
Distruttore chiamato per Oggetto locale in Funzione
In main, al ritorno da fun
Il particolare compilatore usato per eseguire questo esempio non ha chiamato i distruttori per gli oggetti locale (in main), statico (nella funzione) e globale nel programma.
Per gli oggetti locali ad una funzione, il costruttore è chiamato quando l'oggetto è dichiarato e il distruttore è chiamato quando si esce dal blocco in cui è dichiarato l'oggetto.
Per gli oggetti globali il costruttore è chiamato quando inizia il programma e il distruttore è chiamato quando il programma termina.
Per gli oggetti statici il costruttore è chiamato la prima volta che si entra nella funzione in cui l'oggetto è dichiarato e il distruttore è chiamato quando il programma termina.
Accesso ai campi membro
La classe Data non consente l'accesso ai campi membro perchè sono privati. Per consentire di
trattarli anche singolarmente, si devono fornire gli appositi metodi alla classe. Per esempio
class Data {
public:
Data(int g, int m, int a); // Costruttore
// funzioni membro:
int leggiGiorno();
int leggiMese();
int leggiAnno();
void scriviGiorno( int g );
void scriviMese( int m );
void scriviAnno( int a );
void mostra(); // stampa la data
~Data(); // Distruttore
private:
int giorno, mese, anno; // campi privati
};
Questa versione della classe Data include le funzioni membro per leggere e per modificare i campi membro giorno, mese e anno. Le definizioni delle funzioni sono le seguenti:
inline int Data::leggiGiorno() {
return giorno;
}
inline int Data::leggiMese() {
return mese;
}
inline int Data::leggiAnno() {
return anno;
}
void Data::scriviGiorno( int g ) {
static int lung[] = {0,31,28,31,30,31,30,31,31,30,31,30,31};
giorno = max( 1, g );
giorno = min( giorno, lung[mese] );
}
void Data::scriviMese( int m ) {
mese = max( 1, m );
mese = min( mese, 12 );
}
void Data::scriviAnno( int a ) {
anno = max( 1, a );
}
Le varie funzioni leggi ritornano semplicemente il valore del campo. Le funzioni scrivi non si limitano a modificare il campo, ma controllano anche che il parametro passato sia valido. Il seguente esempio usa le nuove funzioni membro:
int main() {
int i;
Data scadenza( 3, 10, 1980 );
i = scadenza.leggiMese(); // legge il valore del mese
scadenza.scriviMese( 4 ); // modifica il valore del mese
scadenza.scriviMese( scadenza.leggiMese() + 1 ); // Incrementa
}
Le funzioni leggi sono dichiarate inline perchè sono brevi.
Adesso che la classe ha le funzioni membro per modificare i suoi campi, si può usare un modo diverso per costruire un oggetto di classe Data. L'esempio seguente definisce due versioni del costruttore di oggetti di classe Data, uno con parametri e uno senza:
class Data {
public:
Data(); // Costruttore senza parametri
Data( int g, int m, int a); // Costruttore con parametri
// etc....
};
Data::Data() {
mese = giorno = anno = 1; // Inizializza i campi
}
Data::Data( int g, int m, int a ) {
scriviGiorno( g );
scriviMese( m );
scriviAnno( a );
}
void main() {
Data unaData; // Dichiara unaa data senza parametri
Data altraData( 12, 25, 1990 );
unaData.scriviMese( 3);
unaData.scriviGiorno( 12 );
unaData.scriviAnno( 1985 );
}
Il primo costruttore crea un oggetto unaData, di classe Data, e lo inizializza a 1 gennaio 1. I valori corretti vengono specificati poi con le funzioni scrivi.
Nella dichiarazione dell'oggetto altraData vengono specificati tre parametri. Questo costruttore chiama le funzioni membro per scrivere i valori nei campi.
Il primo costruttore nell'esempio precedente è chiamato costruttore di default, perchè viene richiamato senza parametri.
Funzioni di accesso ai campi e campi pubblici.
Scrivere le funzioni di accesso potrebbe sembrare un lavoro inutile. È molto più immediato dichiarare
che i campi della classe sono pubblici e così accedere direttamente ai campi.
I vantaggi delle funzioni di accesso, oltre alla possibilità di applicare sempre un controllo di validità del campo, evitando di stampare valori senza significato, riguardano anche la possibilità di apportare modifiche al tipo dei campi. Per esempio, se si decidesse di codificare il giorno e il mese in uno stesso byte, in C si dovrebbe modificare ogni istruzione che usa quei dati. In C++, invece, bisogna riscrivere solo le funzioni membro della classe. Questa modifica non coinvolge il resto del programma che usa la classe Data. Si può ancora continuare a chiamare le funzioni leggi e scrivi come si poteva fare prima della modifica dei campi.
Usando le funzioni membro per accedere ai campi della classe, si nasconde la rappresentazione della classe, consentendo quindi di cambiare l'organizzazione dei dati senza nessun effetto per i programmi che usano la classe. Questa caratteristica è nota come incapsulamento, che è uno dei più importanti principi della programmazione orientata agli oggetti.
Ritornare un riferimento
Potrebbe succedere di vedere programmi C++ che dichiarano funzioni membro come campi pubblici. Tali funzioni
ritornano riferimenti a campi privati. Per esempio:
// Tecnica sbagliata: funzioni membro che ritornano un riferimento
class Data {
public:
Data( int g, int m, int a); // Costruttore
int &mese(); // scrive/legge mese
~Data(); // Distruttore
private:
int campoMese,
campoGiorno,
campoAnno;
};
int &Data::mese() {
campoMese max( 1, campoMese );
campoMese = min( campoMese, 12 );
return campoMese;
}
La funzione campoMese restituisce un riferimento al campo della classe. Questo significa che la chiamata della funzione mese() può essere considerata come un sinonimo per il campo privato. Per esempio:
// tecnica sbagliata: usare una funzione membro per restituire un riferimento
int main() {
int i;
Data scadenza( 3, 10, 1980 );
i = scadenza.mese(); // leggi il valore del mese
scadenza.mese() = 4; // modifica il valore
scadenza.mese()++; // Incrementa
}
La funzione membro si comporta come un campo. Di conseguenza la chiamata di funzione scadenza.mese() può apparire sul lato sinistro di un'assegnazione, nello stesso modo in cui scadenza.campoMese potrebbe trovarsi se fosse pubblico. Addirittura si può incrementare il suo valore con l'operatore ++.
In questo modo si potrebbe assegnare un valore illegale a campoMese, ma la funzione mese() esegue un controllo di validità del campo e corregge i valori sbagliati appena verrà chiamata. Fintantochè tutte le altre funzioni membro non accedono direttamente ai campi membro ma usano sempre le funzioni di accesso la classe funziona correttamente.
Questa tecnica si dovrebbe evitare per vari motivi. Primo: la sintassi potrebbe risultare ambigua. Secondo: il controllo di validità viene eseguito ogni volta che il campo viene letto, introducendo una perdita di tempo. Infine, questa tecnica rende il campo pubblico.
Con questa soluzione si impedisce di apportare eventuali modifiche all'organizzazione dei dati senza riscrivere tutte le sezioni del programma che usano quella classe. Per conservare i vantaggi delle funzioni membro, le classi devono essere sempre dotate di funzioni di accesso in lettura e in scrittura dei campi membro.
Oggetti costanti e funzioni membro
Così come si possono dichiarare identificatori const, così si possono dichiarare anche oggetti
const. Una tale dichiarazione significa che l'oggetto è costante e nessuno dei suoi campi
membro può essere modificato. Per esempio:
const Data compleanno( 7, 4, 1776 );
Questa dichiarazione significa che il valore di compleanno non può essere cambiato. Quando un identificatore viene dichiarato costante, il compilatore impedisce che venga modificato e genera un messaggio di errore. Ciò non è vero per gli oggetti costanti.
Il compilatore non può stabilire se una funzione membro potrebbe modificare uno dei campi membro, così, in forma cautelativa, impedisce di chiamare qualsisasi funzione membro di un oggetto costante.
Comunque ci sono funzioni membro che non modificano i campi dell'oggetto, quindi possono essere chiamate, anche se l'oggetto è costante.
Il termine const posto alla fine dell'elenco dei parametri di una funzione membro, corrisponde a dichiarare che la funzione membro è di sola lettura e non modifica l'oggetto. Il seguente esempio dichiara alcune funzioni membro della classe di tipo costante:
Class Data {
public:
Data(int g, int m, int a ); // Costruttore
// Funzioni Membro:
int leggiGiorno() const; // sola lettura
int leggiMese() const; // sola lettura
int leggiAnno() const; // sola lettura
void scriviGiorno( int g );
void scriviMese( int m );
void scriviAnno( int a ):
void mostra() const; // sola lettura
-Data(): // Distruttore
private:
int mese, giorno, anno;
};
inline int Data::getmese() const {
return mese;
}
// etc...
Le varie funzioni leggi e la funzione mostra() sono funzioni di sola lettura. Notare che il termine const è usato sia nella dichiarazione sia nella definizione di ciascuna funzione membro. Queste funzioni possono essere chiamate per non modificare i campi dell'oggetto. Quando la classe Data viene modificata in questo modo, il compilatore riesce a impedire un eventuale tentativo di modifica dell'oggetto compleanno:
int i;
const Data compleanno( 7, 4, 1776 );
i = compleanno.leggiAnno(); // ammesso
compleanno.scriviAnno( 1492); // Errore: scriviAnno non è const
Il compilatore consente di chiamare la funzione membro non-const leggiAnno per l'oggetto compleanno, ma non permette di chiamare la funzione scriviAnno, che è una funzione non-const
Una funzione membro che è dichiarata const non può modificare i campi membro dell'oggetto e non può nemmeno chiamare una funzione membro non-const. Se una delle funzioni scrivi viene dichiarata const il compilatore segnala un errore.
Quando è necessario conviene dichiarare const le funzioni membro, in modo da poter dichiarare oggetti costanti.
Oggetti membro
Una classe può contenere, tra i suoi campi membro, altri oggetti. L'operazione di dichiarare una classe
dandole altre classi come campi è chiamata composizione.
Si supponga di avere bisogno di una classe infoPersona che memorizzi il nome, l'indirizzo e il compleanno di una persona. La classe Data è un campo membro della classe infoPersona, come nel seguente esempio:
class infoPersona {
public:
// ... funzioni membro pubbliche ...
private:
char nome[30];
char indirizzo[60];
Data compleanno;
};
Nella dichiarazione di questa classe c'è il campo privato compleanno, di classe Data. Notare che nella dichiarazione di compleanno non è stato specificato nessun parametro. Questo non significa che viene chiamato il costruttore di default.
L'oggetto compleanno viene costruito solo al momento in cui viene costruito un oggetto di classe infoPersona.
Per chiamare il costruttore di un oggetto membro, si deve specificare un inizializzatore membro, secondo la sintassi mostrata nell'esempio seguente, cioè si pone un carattere due punti dopo la lista dei parametri del costruttore della classe contenitore e si scrive il nome del campo membro con l'elenco dei parametri:
class InfoPersona {
public:
infoPersona::infoPersona(char *nm, char *ind, int g, int m, int a);
// ...
private:
// ...
};
infoPersona::infoPersona(char *nm, char *ind, int g, int m, int a );
: compleanno(g, m, a) { // inizializzatore membro
strncpy( nome, nm, 30 );
strncpy( indirizzo, ind, 60 );
}
Con questa definizione, il costruttore della classe Data viene richiamato per l'oggetto compleanno, usando i tre parametri specificati.
Il costruttore della classe Data viene chiamato per primo, in modo che l'oggetto compleanno sia inizializzato prima che venga richiamato il costruttore della classe infoPersona.
Bisogna specificare un inizializzatore membro per ogni oggetto contenuto in una classe, ciascuno separato da una virgola. Se il programmatore non specifica un inizializzatore membro, il compilatore chiama il costruttore di default degli oggetti membro, prima di chiamare il costruttore della classe contenitore. In questo caso, il programmatore deve usare le funzioni membro di accesso per assegnare i valori iniziali ai campi degli oggetti contenuti.
Il costruttore della classe infoPersona si può scrivere così:
infoPersona::infoPersona( char *nm, char *ind, int g, int m, int a )
// il costruttore di default imposta la data di compleanno a 1 gennaio 1
{
strncpy( nome, nm, 30 );
strncpy( indirizzo, ind, 60 );
compleanno.scriviGiorno( g );
compleanno.scriviMese( m );
compleanno.scriviAnno( a );
}
Se la classe dell'oggetto membro non definisce un costruttore di default, il compilatore segnala un errore.
La tecnica appena descritta è inefficiente, perchè il valore di compleanno viene scritto due volte. Prima viene inizializzato a 1 gennaio 1 dal costruttore di default, poi gli vengono assegnati i valori tramite le funzioni membro di accesso.
In generale, si dovrebbero usare gli inizializzatori membro per assegnare i valori, al momento della dichiarazione dell'oggetto, agli oggetti membro, a meno che il costruttore di default provveda ad assegnare i valori corretti.
Un inizializzatore membro è richiesto quando una classe contiene un oggetto costante. Poichè il compleanno di una persona non cambia mai, allora il campo compleanno si deve dichiarare const. Per esempio:
class infoPersona {
public:
// ...
private:
char nome[30];
char indirizzo[60];
const Data compleanno; // oggetto membro costante
};
infoPersona::infoPersona( char *nm, char *ind, int g, int m, int a )
// il costruttore di default imposta la data di compleanno a 1 gennaio 1
{
strncpy( nome, nm, 30 );
strncpy( indirizzo, ind, 60 );
compleanno.scrivigiorno( g ); // Errore
compleanno.scriviMese( m ); // Errore
compleanno.scriviAnno( a ); // Errore
}
Siccome compleanno è un oggetto const, non si può chiamare nessuna delle sue funzioni membro non-const. Di conseguenza non si può cambiare la data del compleanno da quella che è stata impostata dal costruttore di default.
Lo stesso vale per ogni campo dichiarato const, anche se è una comune variabile di un tipo predefinito. A una variabile costante non può essere assegnato un valore nel costruttore, si deve usare un inizializzatore membro, come ad esempio:
class Conta {
public:
Conta( int i); // Costruttore
private:
const int cnt; // Campo costante di tipo intero
};
Conta( int i )
: cnt( i ) // inizializzatore membro per l'intero i
{}
Usare un inizializzatore membro per assegnare il valore iniziale ad un campo membro costante in qualsiasi caso, è o non è un oggetto.
File Header e file sorgenti
Per consuetudine, i programmi C++ venogono divisi in due file: uno contiene il codice del programma sorgente, e
l'altro contiene le intestazioni (header). Più precisamente, si inseriscono le dichiarazioni delle classi
in file header (.h) e le definizioni delle funzioni membro in file sorgenti (.cpp). Per ogni classe ci dovrebbero
essere due file separati, aventi lo stesso nome della classe, uno con estensione .h e l'altro con estensione .cpp.
Questo è un esempio di file header della classe Data
// Data.H
#if !defined( _DATA_H_ )
#define _DATA_H_
class Data {
Data();
int leggiMese() const;
// ...
};
inline Data::leggiMese() const {
return mese;
}
// etc...
#endif // _DATA_H_
Questo file header contiene le definizioni delle funzioni membro inline. Infatti il compilatore deve poter accedere alle funzioni inline, per poter inserire le istruzioni di quella funzione, ogni volta che trova una sua chiamata all'interno del programma.
Nel file header ci sono anche la direttiva #if e la direttiva #define. La direttiva #if interroga l'operatore defined per impedire un'inclusione ripetuta dello stesso file nella compilazione del programma, quando un programma è organizzato in molti file.
Questo invece è l'inizio del file sorgente della classe Data:
// Data.CPP
#include "Data.h"
Data::Data(); {
. . .
}
Il file sorgente include il corrispondente file header.
Un file header descrive l'interfaccia della classe e il file sorgente descrive la sua implementazione. Questa distinzione è utile quando le classi possono essere usate da più programmatori.
Per usare la classe Data, per esempio, altri programmatori devono solo includere il file header Data.H nei loro file sorgenti. Questi programmatori non hanno bisogno di vedere come sono implementate le funzioni membro, gli basta vedere i prototipi delle funzioni.
Se posseggono il file compilato (.obj), possono costruire il loro programma eseguibile, senza conoscere il file Data.CPP. Se però si riscrive il file Data.CPP, si deve ricompilare il programma e fornire ai programmatori il nuovo file Data.OBJ. Gli altri programmatori non hanno bisogno di modificare i loro sorgenti.
Nel file header, alcuni aspetti dell'implementazione della classe vengono rivelati. I membri privati della classe sono visibili, anche se non sono accessibili. Inoltre, sono visibili anche le funzioni inline della classe. Se si apportano modifiche ai campi membro privati o alle funzioni inline della classe, tali modifiche compaiono nel file header e tutti i programmatori che usano quella classe devono ricompilare il codice con il nuovo file header. Non devono riscrivere niente fintantochè non è cambiata l'interfaccia della classe, e non sono cambiati i prototipi delle funzioni membro.
Resta anche da considerare se le direttive #include devono trovarsi nel file header o nel file sorgente. Ad esempio se le funzioni membro di una classe ricevono come parametro una struttura time_t, la direttiva #include "time.h" deve essere inserita nel file header. In altri termini, se la struttura time_t viene usata solo nei calcoli interni di una funzione membro e non è visibile a nessuna funzione chiamante, allora posizionare #include "time.h" nel file sorgente. Nel primo caso, l'interfaccia richiede TIME.H, e nel secondo caso, la richiede l'implementazione. Non inserire direttive #include nei file header se è sufficiente inserirle nel file sorgente.
La separazione dell'interfaccia e dell'implementazione di una classe rispetta il principio dell'incapsulamento, che richiede di nascondere i dettagli con cui la classe è realizzata.