[ Suggerimenti ] [ Soluzioni degli Esercizi] [ Volume 2 ] [ Newsletter Gratuita ]
[
Seminari ] [ Seminari su CD ROM ] [ Consulenza]

Pensare in C++, seconda ed. Volume 1

©2000 by Bruce Eckel

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]

traduzione italiana e adattamento a cura di Giuseppe Cicardo

6: Inizializzazione & Pulizia

Nel Capitolo 4 si è migliorato significativamente l'uso delle librerie, riunendo tutti i componenti sparsi di una tipica libreria C e incapsulandoli in una struttura (un tipo dati astratto, che da ora in poi sarà chiamato classe).

In tal modo, non solo si fornisce un unico punto di accesso a un dato componente di una libreria, ma si nascondono anche i nomi delle funzioni all'interno del nome della classe. Il capitolo 5 ha introdotto il concetto di accesso controllato (protezione  delle informazioni). Grazie ad esso il progettista della classe ha un modo di stabilire dei chiari limiti nel determinare cosa può essere manipolato dal programmatore client e cosa invece è oltre il limite. Ciò significa che i meccanismi interni delle operazioni eseguibili dal nuovo tipo dati avvengono a discrezione del progettista della classe e sono sotto il suo controllo, mentre viene reso chiaro ai programmatori client quali sono i membri a cui possono e dovrebbero prestare attenzione.

Insieme, incapsulamento e accesso controllato, semplificano significativamente l'uso di una libreria. Essi forniscono un concetto di nuovo tipo dati che è migliore, sotto diversi aspetti, di quello che esiste nel linguaggio C con i suoi tipi predefiniti. Il compilatore C++ può ora garantire il controllo di coerenza sul nuovo tipo dati, assicurando in tal modo un certo livello di sicurezza quando esso viene utilizzato.

Quando si tratta di sicurezza, comunque, il compilatore può fare molto di più di quanto non faccia il C. In questo e nei prossimi capitoli, saranno trattate ulteriori caratteristiche che sono state ingegnerizzate nel C++, le quali fanno sì che i bugs del vostro programma saltino fuori e vi afferrino, a volte perfino prima di compilare il programma, o più solitamente sotto forma di errori o warnings del compilatore. Pertanto, vi abituerete presto all'insolito scenario di un programma C++ che, se compilato, spesso gira correttamente al primo colpo.

Due importanti fattori legati alla sicurezza del codice sono l'inizializzazione e la pulizia delle variabili. Una grossa fetta di bugs in "C" derivano dal fatto che il programmatore dimentica di inizializzare o ripulire debitamente una variabile. Questo è specialmente vero nel caso di librerie "C", in cui i programmatori client non sanno come inizializzare una struct, o addirittura non sanno neppure che devono farlo. (Le librerie spesso non includono una funzione di inizializzazione, obbligando in tal modo il programmatore client a inizializzare le strutture "manualmente"). La pulizia è un genere di problema particolare, dato che i programmatori C non fanno fatica a scordarsi delle loro variabili una volta che hanno finito di usarle, così che spesso manca qualunque operazione di pulizia si renda necessaria per le strutture delle librerie.

In C++, il concetto di inizializzazione e pulizia è essenziale per un semplice utilizzo delle librerie e per eliminare molti bugs subdoli che vengono inseriti quando il programmatore client dimentica di eseguire tali operazioni. Questo capitolo prende in esame le proprietà del C++ che aiutano a garantire le appropriate operazioni di inizializzazione e pulizia di una variabile.

 

Inizializzazione garantita dal costruttore

Sia la classe Stash che Stack definite in precedenza hanno una funzione denominata initialize( ), il cui nome stesso suggerisce che dovrebbe essere richiamata prima di usare l'oggetto in qualsiasi altro modo. Purtroppo, ciò implica che sia il programmatore client ad assicurare la dovuta inizializzazione. I programmatori client tendono a trascurare dettagli come l'inizializzazione mentre caricano a testa bassa con la fretta di risolvere il loro problema grazie alla vostra fantastica libreria. In C++ l'inizializzazione è troppo importante per essere lasciata al programmatore client. Il progettista della classe può garantire che ogni oggetto venga inizializzato fornendo una funzione speciale chiamata costruttore. Se una classe ha un costruttore, il compilatore richiamerà automaticamente il costruttore nel punto in cui un oggetto viene creato, prima che il programmatore client possa mettergli le mani addosso. Il costruttore non è un'opzione per il programmatore client. Esso viene eseguito dal compilatore al momento in cui l'oggetto è definito.

La prossima sfida è il  nome da assegnare a questa funzione speciale (il costruttore). Sorgono due problemi. Il primo è che qualunque nome si scelga può in teoria collidere con il nome che vi piacerebbe assegnare a un'altra funzione membro della classe. Il secondo è che il compilatore, avendo la responsabilità di invocare  tale funzione autonomamente, deve sempre sapere quale funzione richiamare. La soluzione scelta da Stroustrup sembra la più semplice e anche la più logica: il costruttore ha lo stesso nome della classe. Ed è logico che  tale funzione sia richiamata automaticamente all'inizializzazione.

Ecco una semplice classe con un costruttore:

class X {
  int i;
public:
  X();  // Costruttore
}; 

Adesso, quando si definisce un oggetto

void f() {
  X a;
  // ...
} 

quello che accade è lo stesso che accadrebbe se a fosse un int: viene allocato spazio in memoria per contenere  l'oggetto. Ma quando il programma raggiunge la riga di programma in cui a viene definito, il costruttore viene richiamato automaticamente. Cioé, il compilatore inserisce silenziosamente la chiamata a X::X() per l'oggetto a nel punto in cui esso viene definito. Come per qualunque altra funzione membro della classe, il primo argomento (segreto) passato al costruttore è il puntatore this - l'indirizzo dell'oggetto per cui viene  richiamato. Nel caso del costruttore, comunque, this sta puntando a un blocco di memoria non inizializzato, e sarà appunto compito del costruttore inizializzarlo dovutamente.

Come qualsiasi funzione, il costruttore può avere degli argomenti, permettendo di specificare come creare un oggetto, dargli dei valori iniziali, e così via. Tali argomenti danno modo di garantire che tutte le parti dell'oggetto siano inizializzate con valori appropriati. Ad esempio, se la classe Tree (albero) ha un costruttore con un solo argomento di tipo intero per stabilire l'altezza dell'albero, si potrà creare un oggetto di tipo Tree in questo modo:

Tree t(12);  // albero alto 12 piedi

Se Tree(int) è l'unico costruttore, il compilatore non permetterà di creare oggetti in alcun altro modo. (Il prossimo capitolo parlerà di costruttori multipli e modi diversi di chiamare i costruttori).

Questo è tutto sul costruttore. E' una funzione dal nome speciale, che viene invocata automaticamente dal compilatore nel  momento in cui ciascun oggetto viene creato. Nonostante la sua semplicità, ha un grande valore, in quanto elimina una vasta gamma di problemi e rende più semplice leggere e scrivere il codice. Nel frammento di codice precedente, ad esempio, non si vede una chiamata esplicita a qualche funzione initialize(), concettualmente separata dalla definizione dell'oggetto. In C++, definizione e inizializzazione sono concetti unificati - non si può avere l'una senza l'altra.

Sia il costruttore che il distruttore sono un genere di funzioni molto insolito: non hanno un valore di ritorno. Ciò è completamente diverso da un valore di ritorno void, in cui la funziona non ritorna niente, ma si ha sempre la possibilità di cambiarla perché ritorni qualcosa. I costruttori e i distruttori non ritornano niente, e non c'è alcun’altra possibilità. Portare un oggetto dentro e fuori dal programma sono azioni speciali, come la nascita e la morte, e il compilatore invoca le funzioni autonomamente, per essere certo che avvengano sempre. Se ci fosse un valore di ritorno, e si potesse scegliere il proprio, il compilatore dovrebbe in qualche modo sapere cosa farne, oppure il programmatore client dovrebbe richiamare esplicitamente i costruttori e i distruttori, il che eliminerebbe la loro protezione implicita.

 

Pulizia garantita dal distruttore

Un programmatore C si sofferma spesso sull'importanza dell'inizializzazione, ma più raramente si preoccupa della pulizia. Dopo tutto, cosa è necessario fare per ripulire un int? Basta scordarsene. Con le librerie comunque, semplicemente "lasciar perdere" un oggetto una volta che si è adoperato non è così sicuro. Che dire se l'oggetto modifica qualche dispositivo hardware, o visualizza qualcosa sullo schermo, o alloca spazio in memoria sullo heap? Se viene semplicemente abbandonato, l'oggetto non raggiungerà mai il suo fine uscendo da questo mondo. In C++, la pulizia è importante quanto l'inizializzazione e viene quindi garantita dal distruttore.

La sintassi per il distruttore è simile a quella del costruttore: la funzione ha lo stesso  nome della classe. Tuttavia, il distruttore si distingue dal costruttore per il prefisso ~ (carattere tilde). Inoltre, il distruttore non ha mai argomenti, dato che la distruzione non necessita mai di alcuna opzione. Ecco la dichiarazione per il distruttore:

class Y {
public:
  ~Y();
}; 

Il distruttore viene richiamato automaticamente dal compilatore quando un oggetto esce dal suo campo di visibilità (scope). E' possibile capire dove viene richiamato il costruttore dal punto di definizione di un oggetto, ma l'unica evidenza della chiamata al  distruttore è la parentesi graffa chiusa del blocco che contiene l'oggetto. E il distruttore viene sempre chiamato, perfino quando si usa goto per saltare fuori dal blocco. (goto esiste anche nel C++ per compatibilità retroattiva con il C, e per quelle volte in cui fa comodo.) Si dovrebbe notare che un goto non-locale, implementato tramite le funzioni di libreria C Standard setjmp() e longjmp(), non fa sì che i distruttori vengano richiamati. (Questa è la specifica, anche se il compilatore usato si comporta diversamente. Affidarsi a una caratteristica non riportata nella specifica significa che il codice generato non sarà portabile).

Ecco un esempio che dimostra le proprietà dei costruttori e dei distruttori viste finora:

//: C06:Constructor1.cpp
// Costruttori & distruttori
#include <iostream>
using namespace std;
 
class Tree {
  int height;
public:
  Tree(int initialHeight);  // Costruttore
  ~Tree();       // Distruttore
  void grow(int years);
  void printsize();
};
 
Tree::Tree(int initialHeight) {
  height = initialHeight;
}
 
Tree::~Tree() {
  cout << "all’interno del distruttore di Tree" << endl;
  printsize();
}
 
void Tree::grow(int years) {
  height += years;
}
 
void Tree::printsize() {
  cout << "L’altezza dell’albero è " << height << endl;
}
 
int main() {
  cout << "prima della parentesi graffa aperta" << endl;
  {
    Tree t(12);
    cout << "dopo la creazione dell’albero" << endl;
    t.printsize();
    t.grow(4);
    cout << "prima della parentesi graffa chiusa" << endl;
  }
  cout << "dopo la parentesi graffa chiusa" << endl;
} ///:~

Ecco il risultato sul video:

prima della parentesi graffa aperta
dopo la creazione dell’albero
L’altezza dell’albero è 12
dopo la parentesi graffa chiusa
all’interno del distruttore di Tree
L’altezza dell’albero è 16
dopo la parentesi graffa chiusa

Si può vedere che il distruttore viene richiamato automaticamente alla chiusura del blocco che racchiude l’oggetto (parentesi graffa chiusa).

 

Eliminazione del blocco di definizione

In C, le variabili devono sempre essere definite all'inizio di un blocco, dopo la parentesi graffa aperta. Tale requisito non è insolito nei linguaggi di programmazione, e la ragione spesso addotta è che si tratta di "buon stile di programmazione". Su questo punto ho i miei sospetti. E' sempre sembrato scomodo ai programmatori, saltare avanti e indietro all'inizio del blocco ogni volta che serve una nuova variabile. Anche il codice risulta più leggibile quando la variabile è definita vicino al punto in cui viene usata.

Puo’ darsi che questi siano argomenti di carattere stilistico. In C++, comunque, sorge un problema significativo nell'essere obbligati a definire tutti gli oggetti all'inizio di un blocco. Se esiste un costruttore, esso deve essere richiamato quando l'oggetto viene creato. Ma se il costruttore vuole uno o più argomenti di inizializzazione, come si può sapere se si avranno tali informazioni all'inizio di un blocco? Nella tipica situazione di programmazione, non si avranno. Poiché il C non ha alcun concetto di “privato”, tale separazione fra definizione e inizializzazione non è un problema. Il C++ invece, garantisce che quando si crea un oggetto, esso venga anche inizializzato simultaneamente. Questo assicura di non avere oggetti non inizializzati a spasso per il sistema. Il C non se ne dà pensiero; anzi, incoraggia tale pratica richiedendo di definire tutte le variabili all'inizio di un blocco, quando non necessariamente si hanno tutte le informazioni per l'inizializzazione [38].

In genere il C++ non permette di creare un oggetto prima che si abbiano le informazioni di inizializzazione per il costruttore. Pertanto, il linguaggio non sarebbe attuabile se si dovessero definire tutte le variabili all'inizio di un blocco. Invece, lo stile del linguaggio sembra incoraggiare la definizione di un oggetto il più vicino possibile al punto in cui esso viene usato. In C++, qualunque regola che si applica a un "oggetto", si riferisce automaticamente anche ad un oggetto di un tipo predefinito. Ciò implica che qualunque oggetto di una classe o variabile di un tipo predefinito possano essere definiti ovunque all’interno di un blocco di codice. Implica anche che si può attendere di avere tutte le informazioni necessarie per una variabile prima di definirla, in modo da potere sempre definire e inizializzare allo stesso tempo:

//: C06:DefineInitialize.cpp
// Definire le variabili ovunque
#include "../require.h"
#include <iostream>
#include <string>
using namespace std;
 
class G {
  int i;
public:
  G(int ii);
};
 
G::G(int ii) { i = ii; }
 
int main() {
  cout << "valore di inizializzazione? ";
  int retval = 0;
  cin >> retval;
  require(retval != 0);
  int y = retval + 3;
  G g(y);
} ///:~

come si può notare, vengono eseguite delle istruzioni, dopodiché retval viene definita, inizializzata, e utilizzata per acquisire l'input dell'utente. Infine, vengono definite y e g. Il C invece, non consente di definire una variabile se non all'inizio di un blocco.

Generalmente le variabili dovrebbero essere definite il più vicino possibile al punto in cui vengono utilizzate, ed essere sempre inizializzate al tempo stesso della definizione. (Per i tipi predefiniti, questo diventa un suggerimento stilistico, dato che per essi l'inizializzazione è facoltativa). E' una questione di protezione del codice. Riducendo la durata in cui una variabile è disponibile all'interno di un blocco di codice, si riduce anche l'eventualità che se ne abusi in qualche altro punto del blocco stesso. Questo inoltre, migliora la leggibilità del codice, dato che il lettore non deve saltare continuamente all'inizio del blocco per scoprire il tipo di ogni variabile.

 

cicli "for"

In C++, si vedrà spesso il contatore per un ciclo for definito all'interno dell'espressione for stessa:

for(int j = 0; j < 100; j++) {
    cout << "j = " << j << endl;
}
for(int i = 0; i < 100; i++)
 cout << "i = " << i << endl;

Le istruzioni qui sopra sono importanti casi speciali, che confondono i nuovi programmatori C++.

Le variabili i e j sono definite direttamente dentro l'espressione for (non consentito in C). Sono quindi disponibili per l’uso nel ciclo for. È una sintassi molto comoda poiché il contesto elimina qualsiasi dubbio sullo scopo di i e j, così da non essere costretti ad utilizzare nomi macchinosi come i_contatore_cicli per chiarezza.

Comunque, può risultare un po' di confusione se ci si aspetta che la durata delle variabili i e j si estenda oltre il blocco del ciclo for. Non è così [39].

Il capitolo 3 fa notare che anche le istruzioni while e switch permettono di definire oggetti nelle proprie espressioni di controllo, nonostante tale utilizzo sembri molto meno importante rispetto a quello col ciclo for.

Si deve fare attenzione a variabili locali che nascondono variabili del blocco più esterno. Generalmente, utilizzare per una variabile annidata lo stesso nome di una variabile globale al blocco genera confusione e induce a errori [40] .

Trovo che blocchi di dimensioni ridotte siano un indice di buona progettazione. Se si hanno diverse pagine per un'unica funzione, probabilmente si sta tentando di fare troppe cose con tale funzione. Funzioni più frazionate sono non solo più utili, ma rendono anche più facile scovare i bugs.

 

Allocazione di memoria

Ora che le variabili possono essere definite ovunque all’interno di un blocco di codice, potrebbe sembrare che lo spazio in memoria per una variabile non possa essere allocato fino al momento in cui essa viene definita. In realtà è più probabile che il compilatore segua la pratica del C di allocare tutto lo spazio necessario alle variabili di un dato blocco, nel punto in cui esso inizia con la parentesi graffa aperta. Ma ciò è irrilevante, dato che il programmatore non potrà accedere allo spazio di memoria (che poi è l'oggetto) finché esso non sia stato definito[41]. Nonostante la memoria venga allocata all'inizio del blocco di codice, la chiamata al costruttore non avviene fino alla riga di programma in cui l'oggetto è definito, dal momento che l'identificatore dell'oggetto non è disponibile fino ad allora. Il compilatore si assicura perfino che la definizione dell'oggetto (e quindi la chiamata al costruttore) non avvenga in un punto di esecuzione condizionale, come  all'interno di un'istruzione switch o in un punto che possa essere eluso da un goto. Attivando le istruzioni commentate nel seguente frammento di codice si genererà un errore o uno warning:

//: C06:Nojump.cpp
// Non ammesso saltare oltre i costruttori
 
class X {
public:
  X();
};
 
X::X() {}
 
void f(int i) {
  if(i < 10) {
   //! goto jump1; // Errore: il goto elude l’inizializzazione
  }
  X x1;  // Costruttore richiamato qui
 jump1:
  switch(i) {
    case 1 :
      X x2;  // Costruttore richiamato qui
      break;
  //! case 2 : // Errore: case elude l’inizializzazione
      X x3;  // Costruttore richiamato qui
      break;
  }
} 
 
int main() {
  f(9);
  f(11);
}///:~ 

In questa sequenza di istruzioni, sia goto che switch eluderebbero potenzialmente una riga di programma in cui viene richiamato un costruttore. In tal caso, l’oggetto risulterebbe accessibile benché il suo costruttore non sia stato invocato. Pertanto il compilatore genera un messaggio di errore. Ancora una volta questo garantisce che un oggetto non possa essere creato a meno che non venga anche inizializzato.

Le allocazioni di memoria di cui si è parlato qui avvengono, naturalmente, sullo stack. La memoria viene allocata dal compilatore spostando lo stack pointer verso il "basso" (termine relativo, che può indicare incremento o decremento dell'effettivo valore dello stack pointer, a seconda della macchina utilizzata). Gli oggetti possono anche essere allocati sullo heap tramite l'istruzione new, che verrà esplorata più a fondo nel Capitolo 13.

 

Stash con costruttori e distruttori

Gli esempi dei capitoli precedenti hanno funzioni ovvie che corrispondono ai costruttori e distruttori: initialize( ) e cleanup( ). Ecco l’header file per la classe Stash che fa uso di costruttori e distruttori:

//: C06:Stash2.h
// Con costruttori e distruttori
#ifndef STASH2_H
#define STASH2_H
 
class Stash {
  int size;      // Dimensione di ogni blocco di memorizzazione
  int quantity;  // Numero di blocchi di memorizzazione
  int next;      // Prossimo blocco libero
  // Array di bytes allocato dinamicamente:
  unsigned char* storage;
  void inflate(int increase);
public:
  Stash(int size);
  ~Stash();
  int add(void* element);
  void* fetch(int index);
  int count();
};
#endif // STASH2_H ///:~

Le uniche funzioni membro ad essere cambiate sono initialize( ) e cleanup( ), rimpiazzate con un costruttore e un distruttore:

//: C06:Stash2.cpp {O}
// Costruttori e distruttori
#include "Stash2.h"
#include "../require.h"
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;
 
Stash::Stash(int sz) {
  size = sz;
  quantity = 0;
  storage = 0;
  next = 0;
}
 
int Stash::add(void* element) {
  if(next >= quantity) // Abbastanza spazio rimasto?
    inflate(increment);
  // Copia elemento nello spazio di memorizzazione,
  // cominciando dal prossimo blocco libero:
  int startBytes = next * size;
  unsigned char* e = (unsigned char*)element;
  for(int i = 0; i < size; i++)
    storage[startBytes + i] = e[i];
  next++;
  return(next - 1); // Numero indice
}
 
void* Stash::fetch(int index) {
  require(0 <= index, "Stash::fetch (-)index");
  if(index >= next)
    return 0; // Per indicare la fine
  // Restituisce il puntatore all’elemento desiderato:
  return &(storage[index * size]);
}
 
int Stash::count() {
  return next; // Numero di elementi in CStash
}
 
void Stash::inflate(int increase) {
  require(increase > 0, 
    "Stash::inflate zero or negative increase");
  int newQuantity = quantity + increase;
  int newBytes = newQuantity * size;
  int oldBytes = quantity * size;
  unsigned char* b = new unsigned char[newBytes];
  for(int i = 0; i < oldBytes; i++)
    b[i] = storage[i]; // Copia il vecchio nel nuovo
  delete [](storage); // Vecchio spazio di memorizzazione
  storage = b; // Punta alla nuova zona di memoria
  quantity = newQuantity;
}
 
Stash::~Stash() {
  if(storage != 0) {
   cout << "freeing storage" << endl;
   delete []storage;
  }
} ///:~

Come si può notare, per difendersi da errori di programmazione si fa uso di funzioni definite in require.h, invece di assert( ). Le informazioni fornite da un assert() fallito infatti, non sono utili quanto quelle delle funzioni di require.h (come sarà mostrato più avanti nel libro).

Poiché inflate() è privata, l’unico modo in cui require() può fallire è se una delle altre funzioni membro passa accidentalmente un valore non corretto a inflate(). Se si è certi che questo non possa accadere, si potrebbe considerare di rimuovere la require(), ma occorre tenere in mente che, finché la classe non è stabile, c’è sempre la possibilità di aggiungere del nuovo codice che introduca degli errori. Il costo di require() è basso (e si potrebbe rimuovere automaticamente usando il preprocessore) mentre il valore della robustezza del codice è alto.

Si noti come nel seguente programma di test la definizione di oggetti Stash appaia appena prima che essi servano, e come l’inizializzazione sia parte della definizione stessa, nella lista di argomenti del costruttore:

//: C06:Stash2Test.cpp
//{L} Stash2
// Costruttori e distruttori
#include "Stash2.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
int main() {
  Stash intStash(sizeof(int));
  for(int i = 0; i < 100; i++)
    intStash.add(&i);
  for(int j = 0; j < intStash.count(); j++)
    cout << "intStash.fetch(" << j << ") = "
         << *(int*)intStash.fetch(j)
         << endl;
  const int bufsize = 80;
  Stash stringStash(sizeof(char) * bufsize);
  ifstream in("Stash2Test.cpp");
  assure(in, " Stash2Test.cpp");
  string line;
  while(getline(in, line))
    stringStash.add((char*)line.c_str());
  int k = 0;
  char* cp;
  while((cp = (char*)stringStash.fetch(k++))!=0)
    cout << "stringStash.fetch(" << k << ") = "
         << cp << endl;
} ///:~

Si noti anche come le chiamate a cleanup() siano state eliminate, ma i distruttori vengano sempre richiamati automaticamente quando intStash e stringStash escono dal campo di visibilità.

Una cosa a cui prestare attenzione negli esempi con Stash: sono stato molto attento a far uso soltanto di tipi predefiniti; cioé quelli senza distruttori. Se si tentasse di copiare oggetti di una classe dentro Stash, ci si imbatterebbe in ogni genere di problema e non funzionerebbe correttamente. La Libreria Standard del C++ in effetti può realizzare copie corrette di oggetti nei suoi contenitori (containers), ma si tratta di un processo piuttosto complesso e ingarbugliato. Il seguente esempio, con Stack, farà uso dei puntatori per aggirare questo ostacolo, e in un capitolo successivo anche la classe Stash sarà modificata per usare i puntatori.

 

Stack con costruttori e distruttori

Reimplementando la lista concatenata (contenuta in Stack) facendo uso di costruttori e distruttori, si mostra come questi lavorino elegantemente con le istruzioni new e delete. Ecco l’header file modificato:

//: C06:Stack3.h
// Con costruttori/distruttori
#ifndef STACK3_H
#define STACK3_H
 
class Stack {
  struct Link {
    void* data;
    Link* next;
    Link(void* dat, Link* nxt);
    ~Link();
  }* head;
public:
  Stack();
  ~Stack();
  void push(void* dat);
  void* peek();
  void* pop();
};
#endif // STACK3_H ///:~

Non solo Stack ha un costruttore e il distruttore, ma ce l’ha anche la classe annidata Link:

//: C06:Stack3.cpp {O}
// Costruttori/Distruttori
#include "Stack3.h"
#include "../require.h"
using namespace std;
 
Stack::Link::Link(void* dat, Link* nxt) {
  data = dat;
  next = nxt;
}
 
Stack::Link::~Link() { }
 
Stack::Stack() { head = 0; }
 
void Stack::push(void* dat) {
  head = new Link(dat,head);
}
 
void* Stack::peek() { 
  require(head != 0, "Stack vuoto");
  return head->data; 
}
 
void* Stack::pop() {
  if(head == 0) return 0;
  void* result = head->data;
  Link* oldHead = head;
  head = head->next;
  delete oldHead;
  return result;
}
 
Stack::~Stack() {
  require(head == 0, "Stack non vuoto");
} ///:~

Il costruttore Link::Link( ) inizializza semplicemente i puntatori data e next, per cui nella funzione Stack::push( ) la linea di codice:

head = new Link(dat,head);

non solo alloca un nuovo concatenamento (utilizzando la creazione dinamica di oggetti tramite la parola chiave new, introdotta nel Capitolo 4), ma inizializza anche i suoi puntatori correttamente.

Ci si potrebbe chiedere come mai il distruttore di Link non fa nulla – in particolare, perché non chiama delete sul puntatore data? Ci sono due problemi. Nel Capitolo 4, in cui è stata introdotta la classe Stack, si faceva notare che non si può usare propriamente delete con un puntatore void se esso punta ad un oggetto (asserzione che sarà dimostrata nel Capitolo 13). Ma oltre a questo, se il distruttore di Link eseguisse delete sul puntatore data, la funzione pop() finirebbe col restituire un puntatore ad un oggetto non più esistente, il che sarebbe certamente un bug. Questa viene a volte definita come la questione dell’appartenenza (ownership): Link e quindi Stack detengono solo i puntatori, ma non sono responsabili della loro pulizia. Ciò significa che si deve stare molto attenti a sapere chi ne è il responsabile. Ad esempio, se non si richiama pop() e delete per tutti i puntatori contenuti in Stack, essi non verranno ripuliti automaticamente dal distruttore di Stack. La faccenda può diventare spinosa e portare ai memory leaks (falla di memoria). Pertanto, sapere chi è responsabile della pulizia di un oggetto può fare la differenza fra un programma di successo ed uno pieno di bugs. Ecco perché Stack::~Stack( ) visualizza un messaggio d’errore se al momento della distruzione l’oggetto Stack non è vuoto.

Dal momento che l’allocazione e la pulizia degli oggetti Link sono nascoste all’interno di Stack – essendo parte dell’implementazione interna – tali operazioni non risultano visibili nel programma di test. Tuttavia voi siete responsabili di ripulire i puntatori che vengono restituiti da pop( ):

//: C06:Stack3Test.cpp
//{L} Stack3
//{T} Stack3Test.cpp
// Costruttori/Distruttori
#include "Stack3.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
int main(int argc, char* argv[]) {
  requireArgs(argc, 1); // l’argomento è il nome del file
  ifstream in(argv[1]);
  assure(in, argv[1]);
  Stack textlines;
  string line;
  // Legge il file e memorizza le righe nell’oggetto stack:
  while(getline(in, line))
    textlines.push(new string(line));
  // Ripesca le righe dall’oggetto stack e le visualizza
  string* s;
  while((s = (string*)textlines.pop()) != 0) {
    cout << *s << endl;
    delete s; 
  }
} ///:~

In questo caso, tutte le righe contenute in textlines vengono ripulite tramite delete, ma se non lo fossero, require() genererebbe un messaggio d’errore indicante memory leak.

 

Inizializzazione di aggregati

Un aggregato è proprio quello che suggerisce il nome: un insieme di cose raggruppate insieme. Questa definizione include aggregati di tipi misti, come strutture e classi. Un array invece è un aggregato di elementi di un solo tipo.

Inizializzare aggregati può essere tedioso e indurre facilmente ad errori. L’inizializzazione di aggregati nel C++ è molto più sicura. Quando si crea un oggetto che è un aggregato, tutto quello che si deve fare è un’assegnazione, dopodiché sarà il compilatore ad occuparsi dell’inizializzazione. Tale inizializzazione si presenta in diversi gusti, a seconda del tipo di aggregato con cui si ha a che fare, ma in tutti i casi gli elementi per l’assegnazione devono essere racchiusi fra parentesi graffe. Per un array di tipi predefiniti questo è piuttosto semplice:

int a[5] = { 1, 2, 3, 4, 5 };

Se si tenta di fornire più inizializzatori di quanti siano gli elementi dell’array, il compilatore genera un messaggio d’errore. Ma cosa succede se si forniscono meno inizializzatori? Ad esempio:

int b[6] = {0};

In questo caso il compilatore userà il primo inizializzatore per il primo elemento dell’array, dopodiché userà zero per tutti gli altri elementi. Si noti che tale funzionamento non avviene se si definisce un array senza fornire una lista di inizializzatori. L’espressione di cui sopra dunque è un modo conciso di inizializzare un array a zero, senza usare un ciclo for, e senza quindi alcuna possibilità di commettere un errore di superamento dell'indice dell’array (off-by-one error) (a seconda del compilatore può anche essere più efficiente del ciclo for.)

Un’altra scorciatoia per gli array è il conteggio automatico, con cui si lascia che sia il compilatore a determinare la dimensione dell’array in base al numero di inizializzatori forniti:

int c[] = { 1, 2, 3, 4 };

Se ora si decide di aggiungere un altro elemento all’array, basta aggiungere un inizializzatore. Se si riesce a impostare il proprio codice in modo tale da doverlo modificare in un solo punto, si riducono le probabilità di commettere errori durante le modifiche. Ma come si determina la dimensione dell’array? L’espressione sizeof c / sizeof * c (dimensione dell’intero array diviso per la dimensione del primo elemento) assolve il compito senza bisogno di essere modificata anche se dovesse cambiare la dimensione dell’array [42]:

for(int i = 0; i < sizeof c / sizeof *c; i++)
 c[i]++;

Poiché le strutture sono anche degli aggregati, esse possono essere inizializzate in maniera simile. Dato che in una struct stile C tutti i membri sono pubblici, essi possono essere assegnati direttamente:

struct X {
  int i;
  float f;
  char c;
};
 
X x1 = { 1, 2.2, 'c' };

In caso si abbia un array di tali oggetti, questi possono essere inizializzati utilizzando una coppia di parentesi graffe annidata per ciascun oggetto:

X x2[3] = { {1, 1.1, 'a'}, {2, 2.2, 'b'} };

In questo caso, il terzo oggetto viene inizializzato a zero.

Se qualche membro è privato (tipico per una classe C++ ben progettata), o anche se tutto è pubblico, ma c’è un costruttore, le cose sono diverse. Negli esempi di cui sopra, gli inizializzatori vengono assegnati direttamente agli elementi dell’aggregato, ma i costruttori sono un mezzo per forzare l’inizializzazione attraverso un’interfaccia formale. In tal caso è necessario richiamare i costruttori per effettuare l’inizializzazione. Quindi se si ha una struct che assomiglia alla seguente:

struct Y {
  float f;
  int i;
  Y(int a);
}; 

E’ necessario indicare le chiamate ai costruttori. L’approccio migliore è quello esplicito, come segue:

Y y1[] = { Y(1), Y(2), Y(3) };

Si ottengono tre oggetti e tre chiamate al costruttore. Ogni qualvolta si abbia un costruttore, sia che si tratti di una struct con tutti i membri public o di una class con dei membri private, tutte le inizializzazioni devono avvenire attraverso il costruttore, anche se si sta usando la forma per inizializzare aggregati.

Ecco un secondo esempio che mostra un costruttore con più argomenti:

//: C06:Multiarg.cpp
// Costruttore con più argomenti
// in caso di inizializzazione di aggregati
#include <iostream>
using namespace std;
 
class Z {
  int i, j;
public:
  Z(int ii, int jj);
  void print();
};
 
Z::Z(int ii, int jj) {
  i = ii;
  j = jj;
}
 
void Z::print() {
  cout << "i = " << i << ", j = " << j << endl;
}
 
int main() {
  Z zz[] = { Z(1,2), Z(3,4), Z(5,6), Z(7,8) };
  for(int i = 0; i < sizeof zz / sizeof *zz; i++)
    zz[i].print();
} ///:~

Sembra proprio che per ogni oggetto nell’array si abbia una chiamata esplicita al costruttore.

 

Costruttori di default

Un costruttore di default è un costruttore che può essere richiamato senza argomenti (o parametri). Viene usato per creare un "oggetto standard", ma è importante anche quando si chiede al compilatore di creare un oggetto senza fornire ulteriori dettagli. Per esempio, se si usa la struct Y definita in precedenza in una definizione come questa:

Y y2[2] = { Y(1) };

il compilatore lamenterà di non poter trovare il costruttore di default. Il secondo oggetto nell’array vuole essere creato senza argomenti, ed è qui che il compilatore cerca il costruttore di default. Infatti, se si definisce semplicemente un array di oggetti di tipo Y,

Y y3[7];

il compilatore si lamenterà perché ha bisogno di un costruttore di default per inizializzare ogni oggetto dell’array.

Lo stesso problema occorre se si crea un singolo oggetto in questo modo:

Y y4;

Ricordate, se c’è un costruttore, il compilatore assicura che la costruzione avvenga sempre, a prescindere dalla situazione.

Il costruttore di default è così importante che se - e solo se - non si è definito alcun costruttore per una struttura (struct o class), il compilatore ne creerà uno automaticamente. Pertanto il seguente codice funziona:

//: C06:AutoDefaultConstructor.cpp
// Costruttore di default generato automaticamente
 
class V {
  int i;  // privato
}; // Nessun costruttore
 
int main() {
  V v, v2[10];
} ///:~

Se almeno un costruttore è stato definito, comunque, ma non il costruttore di default, le istanze di V nell’esempio sopra genereranno errori di compilazione. Si potrebbe pensare che il costruttore sintetizzato dal compilatore debba eseguire qualche forma di inizializzazione intelligente, come azzerare la zona di memoria dell’oggetto. Ma non lo fa. Se lo facesse appesantirebbe il programma, senza alcun controllo da parte del programmatore.  Se si vuole che la memoria venga azzerata, bisogna farlo esplicitamente, scrivendo il proprio costruttore di default.

Nonostante il compilatore sintetizzi un costruttore di default autonomamente, difficilmente il suo comportamento sarà quello desiderato. Si dovrebbe considerare tale caratteristica come una rete di sicurezza, da usarsi raramente. In generale, si dovrebbero definire i propri costruttori esplicitamente e non lasciarlo fare al compilatore.

 

Sommario

I meccanismi apparentemente elaborati forniti dal C++ dovrebbero suggerire chiaramente l’estrema importanza che il linguaggio pone sull’inizializzazione e la pulizia. Nel progettare il C++, una delle prime osservazioni fatte da Stroustrup sulla produttività del linguaggio C, fu che una grossa fetta di problemi nella programmazione deriva dall’impropria inizializzazione di variabili. Tali bugs sono difficili da scovare, e considerazioni simili valgono anche per la pulizia impropria. Poiché i costruttori e i distruttori consentono di garantire l’appropriata inizializzazione e pulizia (il compilatore non permette che un oggetto venga creato e distrutto senza le dovute chiamate al costruttore e al distruttore), si assume un controllo completo in piena sicurezza.

L’inizializzazione di aggregati è stata inclusa con la stessa filosofia – impedisce di commettere i tipici errori di inizializzazione con aggregati di tipo predefinito e rende il codice più conciso.

La protezione del codice è una questione primaria in C++. L’inizializzazione e la pulizia ne costituiscono una parte importante, ma progredendo nella lettura del libro se ne incontreranno altre.

Esercizi

Si possono trovare le soluzioni ad esercizi scelti nel documento elettronico The Thinking in C++ Annotated Solution Guide, disponibile per una piccola somma su www.BruceEckel.com.

  1. Scrivete una semplice classe dal nome Semplice con un costruttore che visualizzi qualcosa per dirvi che è stato richiamato. Create un oggetto di tale classe nel main().
  2. Aggiungete un distruttore all’Esercizio 1, che visualizzi un messaggio per dirvi che è stato richiamato.
  3. Modificate l’Esercizio 2, in modo che la classe contenga un membro di tipo int. Modificate il costruttore così che riceva un int come argomento e lo memorizzi nel membro della classe. Sia il costruttore che il distruttore dovrebbero visualizzare il valore dell’int come parte del loro messaggio, in modo che possiate vedere gli oggetti mentre vengono creati e distrutti.
  4. Dimostrate che i distruttori vengono richiamati anche quando si salta fuori da un ciclo per mezzo di un goto.
  5. Scrivete due cicli for che visualizzino i valori da zero 10. Nel primo, definite la variabile usata come contatore prima del ciclo for. Nel secondo ciclo definitela nell’espressione di controllo del ciclo stesso. Come ulteriore esercizio, assegnate alla variabile del secondo ciclo for lo stesso nome di quella del primo ciclo, e vedete cosa fa il compilatore.
  6. Modificate i file Handle.h, Handle.cpp e UseHandle.cpp visti alla fine del Capitolo 5, in modo che facciano uso di costruttori e distruttori.
  7. Usate l’inizializzazione di aggregati per creare un array di double in cui specificate la dimensione dell’array, ma non fornite un numero sufficiente di elementi. Visualizzate la dimensione dell’array utilizzando l’operatore sizeof. Adesso create un array di double usando l’inizializzazione di aggregati e il conteggio automatico. Visualizzate l’array.
  8. Utilizzate l’inizializzazione di aggregati per creare un array di oggetti string. Create un oggetto Stack per contenere tali oggetti string e scandite l’array inserendo (push) ciascuna string nel vostro Stack.
  9. Dimostrate il conteggio automatico e l’inizializzazione di aggregati con un array di oggetti della classe creata nell’Esercizio 3. Aggiungete una funzione membro a tale classe che visualizzi un messaggio. Calcolate la dimensione dell’array e spostatevi su di esso, richiamando la vostra nuova funzione membro.
  10. Create una classe senza alcun costruttore, e mostrate che potete creare oggetti tramite il costruttore di default. Ora create un costruttore non di default (che abbia un argomento) per la classe, e provate  a compilare di nuovo. Spiegate cosa è successo.

 


[38] C99, la versione aggiornata del C Standard, permette di definire le variabili in qualsiasi punto di un blocco, come il C++.

[39] Una precedente revisione della bozza standard del C++ diceva che la durata della variabile si estendeva fino alla fine del blocco che racchiudeva il ciclo for. Alcuni compilatori la implementano ancora in questo modo, ma non è corretto, pertanto il codice sarà portabile solo se si limita la visibilità della variabile al ciclo for.

[40] Il linguaggio Java lo considera talmente una cattiva idea da segnalare tale codice come errato.

[41] OK, probabilmente potreste pasticciare con i puntatori, ma sareste molto, molto cattivi.

[42] Nel Volume 2 di questo libro (disponibile gratuitamente in lingua Inglese su www.BruceEckel.com), si vedrà un modo più conciso di calcolare la dimensione di un array utilizzando i template.

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]
Ultimo aggiornamento: 25/02/2003