Dal C al C++/Classi isolate

Indice del libro

I costruttori

modifica

Le seguenti istruzioni dichiarano alcune variabili:

int a1;
int a2 = 3;
complex<double> c1;
complex<double> c2(2.4, 5.9);
string s1;
string s2 = "abc";
vector<float> vf1;
vector<float> vf2(8);

Trattandosi di variabili di tipo valore, cioè né puntatori né riferimenti, ad esse corrisponde un oggetto del tipo indicato alla loro sinistra. Per ognuno dei quattro tipi presentati, vengono dichiarate due variabili, delle quali una non è inizializzata esplicitamente, e l'altra sì.

La variabile "a1" essendo di un tipo fondamentale, non viene affatto inizializzata. Tutte le altre variabili sono invece inizializzate, in quanto in assenza di un inizializzatore esplicito ne viene eseguito uno implicito.

Tali tipi non fondamentali, per quanto facenti parte della libreria standard, non fanno parte del linguaggio e sono quindi implementati usando il linguaggio stesso.

Qui vedremo, per gradi, come è possibile realizzare una classe che abbia tale comportamento.

Proviamo, per prendere un caso concreto, a realizzare una classe che implementi il tipo "complex<double>", rinunciando alla genericità del template, e chiamiamo "Complex" tale classe.

Un primo abbozzo è il seguente:

 struct Complex {
    double re_; // Parte reale
    double im_; // Parte immaginaria
 };

Possiamo dichiarare un oggetto di tale classe nel seguente modo:

Complex c;

Tuttavia, il nostro oggetto non sarà inizializzato. Per inizializzare automaticamente un oggetto, si deve definire una funzione membro che il compilatore chiami automaticamente alla creazione dell'oggetto. Tale funzione membro viene detta essere un "costruttore".

Per far sapere al compilatore che tale funzione è un costruttore, basta assegnare a tale funzione lo stesso nome della classe, come nel seguente codice:

 struct Complex {
    Complex() { re_ = 0; im_ = 0; }
    double re_; // Parte reale
    double im_; // Parte immaginaria
 };

Quindi, non si può dare a una funzione membro lo stesso nome della classe, se non per indicare che si tratta di un costruttore. A questo punto il nostro oggetto è automaticamente inizializzato con entrambe le variabili membro poste a zero.

Ora vorremmo che funzionasse anche la seguente inizializzazione esplicita:

Complex c(2.4, 5.9);

A tale scopo, è necessario dichiarare un costruttore che accetti due parametri, come nel seguente codice:

 struct Complex {
    Complex() { re_ = 0; im_ = 0; }
    Complex(double re, double im) { re_ = re; im_ = im; }
    double re_; // Parte reale
    double im_; // Parte immaginaria
 };

Questo codice è corretto, in quanto si possono definire più costruttori sovraccaricati, purché, come tutte le funzioni sovraccaricate, differiscano per il tipo dei parametri. Tuttavia è anche possibile, e più compatto, usare i valori di default per i parametri, come nel seguente codice:

 struct Complex {
    Complex(double re = 0, double im = 0) { re_ = re; im_ = im; }
    double re_; // Parte reale
    double im_; // Parte immaginaria
 };

I distruttori

modifica

Visto il successo della creazione di una classe per i numeri complessi, proviamo a creare una classe per le stringhe, che chiamiamo "String".

 struct String {
    String(const char * s = "") {
       lunghezza_ = strlen(s);
       capacita_ = lunghezza_ + 1;
       array_ = new char[capacita_];
       strcpy(array_, s);
    }
    char * array_; // Array di caratteri
    int capacita_; // Spazio allocato
    int lunghezza_; // Spazio utilizzato
 };

Il costruttore riceve un puntatore a carattere che si assume punti all'inizio di una stringa del C, cioè a un array di caratteri che termina con uno zero binario. Se si costruisce un oggetto di tipo "String" non inizializzato esplicitamente, il costruttore viene chiamato comunque, ricevendo il valore di default del parametro, che è una stringa vuota. Quindi l'oggetto viene sempre correttamente inizializzato.

L'oggetto contiene tre variabili membro:

  • "array_", che è un puntatore a un array di caratteri allocato dinamicamente, e che contiene i caratteri della stringa, compreso il terminatore di stringa.
  • "capacita_", che indica la lunghezza dell'array di caratteri.
  • "lunghezza_", che indica il numero di caratteri effettivamente validi nell'array di caratteri.

L'oggetto sembra funzionare correttamente, ma ha un grosso problema: la memoria dinamica allocata da "new" nel costruttore non viene mai rilasciata. Si tratta di un cosiddetto "memory leak", cioè di una "perdita di memoria".

Il problema è dovuto al fatto che nel costruttore è stata allocata una risorsa, nella fattispecie un array di caratteri, e non c'è nessuna istruzione che la dealloca. Per fortuna, il linguaggio C++, come si occupa di richiamare automaticamente un costruttore alla creazione di un oggetto, così alla distruzione di un oggetto si occupa di richiamare automaticamente un'apposita funzione membro, detta "distruttore". Tutte le risorse allocate nel costruttore devono essere deallocate nel distruttore.

Mentre ci possono essere più costruttori, il distruttore, se presente, è unico, e non riceve parametri. Viene identificato dal fatto che il suo nome è uguale a quello della classe (come per i costruttori), e dal fatto che il suo nome è preceduto dal carattere "~".

Ecco un programma completo che usa costruttore e distruttore:

 #include <iostream>
 using namespace std;
 struct String {
    String(const char * s = "") { // Costruttore
       lunghezza_ = strlen(s);
       capacita_ = lunghezza_ + 1;
       array_ = new char[capacita_];
       strcpy(array_, s);
    }
    ~String() { delete array_; } // Distruttore
    const char *c_str() const { return array_; }
    char * array_; // Array di caratteri
    int capacita_; // Spazio allocato
    int lunghezza_; // Spazio utilizzato
 };
 int main() {
    String s1;
    String s2 = "abcd";
    String s3("XYZ");
    cout << "[" << s1.c_str() << "]"
       << "[" << s2.c_str() << "]"
       << "[" << s3.c_str() << "]";
 }

Il programma stampa "[][abcd][XYZ]".

La variabile "s1" non è inizializzata esplicitamente, ma il suo costruttore viene chiamato comunque, senza parametri; quindi viene usato il valore di default dell'unico parametro.

Le variabili "s2" e "s3" sono inizializzate esplicitamente, con due sintassi diverse, ma con la stessa semantica, e quindi il loro costruttore viene chiamato allo stesso modo.

È stata aggiunta anche la funzione "c_str" per rendere l'uso della classe identico a quello della classe standard "string". Tale funzione è "const", in quanto la chiamata a tale funzione non modifica l'oggetto di tipo "String" a cui si applica, e rende un puntatore "const char", in quanto non si vuole consentire la modifica dell'array interno tramite questo puntatore.

Separazione tra interfaccia e implementazione

modifica

Un programma di dimensioni non banali in linguaggio C è composto da più unità di compilazione, che sono i file che tipicamente hanno suffisso ".c", e da più file di intestazione applicativi, che tipicamente hanno suffisso ".h". Nei file di intestazione si pongono le dichiarazioni delle variabili, costanti, funzioni, e tipi globali. Nelle unità di compilazione si pongono le definizioni delle variabili e funzioni globali, nonché dichiarazioni e definizioni di variabili, costanti, funzioni, e tipi locali al modulo.

In C++ tutto questo viene conservato, ma solitamente si usa un'estensione diversa per le unità di compilazione. L'estensione più usata è ".cpp" (per "C plus plus"), anche se alcuni usano ".cxx" ("C++" con i "più" ruotati), o ".cc", o ".C" con la lettera maiuscola. Per i file di intestazione l'estensione è più usata è ".h", come in C, ma alcuni usano ".hpp" o ".hxx".

In una classe una funzione membro può essere definita o anche soltanto dichiarata, cioè si può inserire solo quello che in C si chiama prototipo di funzione.

Per separare la definizione dalla dichiarazione, si procede come nel seguente esempio, in cui si suppone di avere tre file:

"main.cpp", "c.cpp" e "c.h".
 // Contenuto del file "c.h"
 #ifndef C_H
 #define C_H
 struct C {
    int n;
    bool f();
    int g();
 };
 #endif

 // Contenuto del file "c.cpp"
 #include "c.h"
 bool C::f() { return n > 0; }

 // Contenuto del file "main.cpp"
 #include <iostream>
 #include "c.h"
 using namespace std;
 int main() {
    C c;
    c.n = 0;
    cout << c.f() << " ";
    c.n = 3;
    cout << c.f();
 }

Per generare un eseguibile, bisogna compilare i file "main.cpp" e "c.cpp", e poi linkarli insieme. Il programma stampa "0 1".

Il file "c.h" contiene le cosiddette "guardie di compilazione" che sono le direttive per il preprocessore "#ifndef", "#define" e "#endif". Servono a evitare che il tale file sia letto più volte.

Infatti, per ogni unità di compilazione, ogni definizione di classe deve essere incontrata una sola volta dal compilatore. Provando a togliere tali direttive, in realtà il programma è ancora corretto, ma basta duplicare una direttiva di inclusione del file di intestazione, che il compilatore si lamenta che "struct C" è definito più volte. Ovviamente, tutti i programmatori evitano di includere un file di intestazione più volte nella stessa unità di compilazione, ma siccome gli stessi file di intestazione spesso includono altri file di intestazione, è facile giungere a inclusioni multiple.

Nella definizione di "struct C", le funzioni "f" e "g" hanno solo la dichiarazione, come nei prototipi di funzione. Questo è sufficiente a compilare il file "main.cpp", ma, se non esistesse il file "c.cpp", il linker lamenterebbe l'assenza della definizione della funzione "f", vista come membro della classe "C", in quanto tale funzione viene chiamata dalla funzione "main". Per la funzione "g" non è necessaria nessuna definizione, in quanto non è mai utilizzata.

Il file "c.cpp" include il file "c.h" e poi definisce il corpo della funzione membro "f".

Siccome qui siamo all'esterno della classe, definire semplicemente una funzione "f" non sarebbe una corretta definizione di funzione membro, in quanto rimarrebbe indeterminata la classe a cui appartiene tale funzione. Pertanto, si usa l'espressione "C::f", in cui il nome della funzione è qualificato dal nome della classe. I due caratteri di "due punti" costituiscono un unico operatore, detto "operatore di risoluzione di ambito" (in inglese, "scope resolution operator").

Sarebbe stato errato scrivere "C: :f", mentre "C :: f" sarebbe stato corretto.

L'operatore "::", che non esiste in linguaggio C, serve a indicare, scrivendolo alla sua sinistra, in quale "struct", "classe" o "namespace" va ricercato l'identificatore citato alla sua destra.