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