MindView Inc.

[ 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 ]

trad. italiana e adattamento a cura di Giacomo Grande

11: I Riferimenti & il Costruttore di Copia

I Riferimenti sono come i puntatori costanti, che sono dereferenziati automaticamente dal compilatore.

Benchè i riferimenti esistano anche in Pascal, la versione del C++ è stata presa dal linguaggio Algol. È essenziale in C++ supportare la sintassi dell'overloading degli operatori (vedi Capitolo 12), ma c'è anche una generale convenienza  nel controllare come gli argomenti vengono passati dentro e fuori le funzioni.

In questo capitolo daremo prima brevemente uno sguardo  alle differenze tra i puntatoti in C e C++, per poi introdurre i riferimenti. Ma la parte più consistente del capitolo è rivolta a indagare su un aspetto abbastanza confuso per i nuovi programmatori in C++:  il costruttore di copia, uno speciale costruttore (che richiede i riferimenti) che costruisce un nuovo oggetto a partire da un oggetto già esistente dello stesso tipo. Il costruttore di copia viene usato dal compilatore per passare alle funzioni e ritornare dalle funzioni oggetti per valore.

Infine viene fatta luce su una caratteristica oscura del C++, che è il puntatore-a-membro.

Puntatori in C++

La differenza principale che c'è tra i puntatori in C e quelli in C++ è che il C++ è un linguaggio fortemente tipizzato. Questo viene fuori, ad esempio, laddove  abbiamo a che fare con void*. Il C non permette di assegnare a caso un puntatore di un tipo ad un altro tipo, ma esso permette di fare ciò attraverso void*. Ad esempio:

bird* b;
rock* r;
void* v;
v = r;
b = v;

Siccome questa "caratteristica" del C permette di trattare, di nascosto, qualunque tipo come qualunque altro, esso apre una voragine nel sistema dei tipi. Il C++ non permette ciò; il compilatore da un messaggio di errore, e se si vuole davvero trattare un tipo come un altro, bisogna farlo esplicitamente usando il cast, sia per il compilatore che per il lettore. (Il Capitolo 3 ha introdotto la sintassi migliorativa del casting "esplicito" del C++.)

Riferimenti in C++

Un riferimento (&) è come un puntatore costante, che viene automaticamente dereferenziato. Viene usato generalmente per le liste di argomenti e per i valori di ritorno delle funzioni. Ma si possono costruire anche riferimenti isolati. Per esempio,

//: C11:FreeStandingReferences.cpp
#include <iostream>
using namespace std;

// Riferimento ordinario isolato:
int y;
int& r = y;
// Quando viene creato un riferimento, deve essere 
// inizializzato per riferirsi ad un oggetto vero. 
// Tuttavia si può anche scrivere:
const int& q = 12;  // (1)
// I riferimenti sono legati all'indirizzo di memoria di qualcos'altro:
int x = 0;          // (2)
int& a = x;         // (3)
int main() {
  cout << "x = " << x << ", a = " << a << endl;
  a++;
  cout << "x = " << x << ", a = " << a << endl;
} ///:~

Nella linea (1), il compilatore alloca uno spazio di memoria, lo inizializza con il valore 12 e fissa un riferimento a questo spazio di memoria. Il punto è che un riferimento deve essere legato a qualche altro pezzo di memoria. Quando si accede ad un riferimento, di fatto si accede a questo pezzo di memoria. Così, se scriviamo linee come la (2) e la (3), allora incrementare a corrisponde di fatto a incrementare x, come è mostrato nel main( ). Diciamo che il modo più semplice di pensare ad un riferimento è come un puntatore elaborato. Un vantaggio di questo "puntatore" è che non bisogna preoccuparsi di inizializzarlo (il compilatore ne forza l'inizializzazione) e come dereferenziarlo (lo fa il compilatore).

Ci sono determinate regole quando si usano i riferimenti:

  1. Un riferimento deve essere inizializzato quando viene creato. (I puntatori possono essere inizializzati in qualunque momento.)
  2. Quando un riferimento viene inizializzato per riferirsi ad un oggetto, non può essere cambiato per riferirsi ad un altro oggetto. (I puntatori possono essere puntati verso un altro oggetto in qualunque momento.)
  3. Non esiste il riferimento NULL . Deve essere sempre possibile assumere che un riferimento sia legato a un pezzo di memoria valido.

I Riferimenti nelle funzioni

Il posto più comune dove si possono trovare i riferimenti è negli argomenti di funzioni e nei valori di ritorno delle stesse. Quando un riferimento viene usato come argomento di una funzione, ogni modifica al riferimento dentro la funzione causa cambiamenti all'argomento fuori dalla funzione. Naturalmente si può ottenere lo stesso effetto con un puntatore, ma un riferimento ha una sintassi molto più pulita. (Si può pensare, se volete, ai riferimenti come una pura convenienza sintattica.)

Se si ritorna un riferimento da una funzione, bisogna usare la stessa accortezza che si userebbe se si ritornasse un puntatore. Qualunque sia l'oggetto a cui un riferimento è connesso, questo non deve essere buttato via al ritorno dalla funzione, altrimenti si avrebbe un riferimento a una zona di memoria sconosciuta.

Qui c'è un esempio:

//: C11:Reference.cpp
// Semplici riferimenti del C++

int* f(int* x) {
  (*x)++;
  return x; // Sicuro, x è fuori da questo scope
}

int& g(int& x) {
  x++; // Lo stesso effetto che in f()
  return x; // Sicuro, fuori da questo scope
}

int& h() {
  int q;
//!  return q;  // Errore
  static int x;
  return x; // Sicuro, x vive fuori da questo scope
}

int main() {
  int a = 0;
  f(&a); // Brutto (ma esplicito)
  g(a);  // Pulito (ma nascosto)
} ///:~

La chiamata alla funzione f( ) non ha la stessa convenienza e chiarezza dell'uso del riferimento, ma è chiaro che viene passato un indirizzo. Nella chiamata alla funzione g( ), viene passato un indirizzo (attraverso un riferimento), ma non lo si vede.

riferimenti const

L'argomento passato per riferimento in Reference.cpp funziona solo quando l'argomento è un oggetto non-const. Se è un oggetto const, la funzione g( ) non accetta l'argomento, il che in effetti è una buona cosa, perchè la funzione effettua modifiche all'argomento esterno. Se sappiamo che la funzione rispetterà la  constanza di un oggetto, allora porre l'argomento come riferimento const permette di usare la funzione in qualunque situazione. Questo significa che, per i tipi predefiniti, la funzione non deve modificare gli argomenti, mentre per i tipi definiti dall'utente la funzione deve chiamare solo funzioni membro const e non deve modificare nessun dato membro public.

L'uso di riferimenti const negli argomenti di funzioni  è importante soprattutto perchè la funzione può ricevere oggetti temporanei. Questo può essere stato creato come valore di ritorno da un'altra funzione o esplicitamente dall'utente della nostra funzione. Gli oggetti temporanei sono sempre const, per cui se non si usa un riferimento const, l'argomento non viene accettato dal compilatore. Come esempio molto semplice,

//: C11:ConstReferenceArguments.cpp
// Passaggio dei riferimenti come const

void f(int&) {}
void g(const int&) {}

int main() {
//!  f(1); // Errore
  g(1);
} ///:~

La chiamata a  f(1) causa un errore di compilazione, perchè il compilatore deve prima creare un riferimento. E questo lo fa allocando memoria per un int, inizializzandola a 1 e producendo l'indirizzo da legare al riferimento. Il dato memorizzato deve essere const perchè cambiarlo non ha senso – non si possono mai mettere le mani su di esso. Bisogna fare la stessa assunzione per tutti gli oggetti temporanei: cioè che sono inaccessibili. Il compilatore è in grado di valutare quando si sta cambiando un dato del genere, perchè il risultato sarebbe una perdita di informazione.

Riferimenti a puntatori

In C, se si vuole modificare il contenuto di un puntatore piuttosto che l'oggetto a cui punta, bisogna dichiarare una funzione come questa:

void f(int**);

e bisogna prendere l'indirizzo del puntatore quando lo si passa alla funzione:

int i = 47;
int* ip = &i;
f(&ip); 

Con i riferimenti in C++, la sintassi è piu chiara. L'argomento della funzione diventa un riferimento a un puntatore, e non bisogna prendere l'indirizzo del puntatore.

//: C11:ReferenceToPointer.cpp
#include <iostream>
using namespace std;

void increment(int*& i) { i++; }

int main() {
  int* i = 0;
  cout << "i = " << i << endl;
  increment(i);
  cout << "i = " << i << endl;
} ///:~

Facendo girare questo programma si può provare che è il puntatore ad essere incrementato e non ciò a cui punta.

Linee guida sul passaggio degli argomenti

Dovrebbe essere normale abitudine passare gli argomenti ad una funzione come riferimento const. Anche se questo potrebbe sulle prime sembrare solo un problema di efficienza (e noi non vogliamo preoccuparci di affinare l'efficienza mentre progettiamo e implementiamo un programma), c'è molto di più in gioco: come vedremo nel resto del capitolo, per passare un oggetto per valore è necessario un costruttore di copia e questo non sempre è disponibile.

Il miglioramento dell'efficienza può essere sostanziale se si usa una tale accortezza: passare un argomento per valore richiede la chiamata a un costruttore e a un distruttore, ma se non si deve modificare l'argomento, il passaggio come riferimento const richiede soltanto un push di un indirizzo sullo stack.

Infatti, l'unica occasione in cui non è preferibile passare un indirizzo è quando si devono fare tali modifiche all'oggeto che il passaggio per valore è il solo approccio sicuro (piuttosto che modificare l'oggetto esterno, cosa che il chiamante in genere non si aspetta). Questo è l'argomento della prossima sezione.

Il costruttore di copia

Adesso che abbiamo capito i concetti base dei riferimenti in C++, siamo pronti per affrontare uno dei concetti più confusi del linguaggio: il costruttore di copia, spesso chiamato X(X&) ("rif X di X "). Questo costruttore è essenziale per controllare il passaggio e il ritorno di tipi definiti dall'utente durante le chiamate a funzioni. È così importante, di fatto, che il compilatore sintetizza automaticamente un costruttore di copia se non se ne fornisce uno, come vedremo.

Il passaggio & il ritorno per valore

Per comprendere la necessità del costruttore di copia, consideriamo il modo in cui il C gestisce il passaggio e il ritorno di variabili per valore durante le chiamate a funzioni. Se dichiariamo una funzione e la chiamiamo,

int f(int x, char c);
int g = f(a, b);

come fa il compilatore a sapere come passare e restituire queste variabili? È gia noto! Il range dei tipi con cui ha a che fare è così piccolo – char, int, float, double, e le loro varianti – che questa informazione è già presente nel compilatore.

Se facciamo generare il codice assembly al nostro compilatore e andiamo a vedere le istruzioni generate per la chiamata alla funzione f( ), vedremmo qualcosa equivalente a:

push  b
push  a
call  f()
add  sp,4
mov  g, register a

Questo codice è stato significativamente ripulito per renderlo generico; le espressioni per b ed a potrebbero essere diverse, a seconda che le variabili sono globali (in qual caso esse saranno _b e _a) o locali (il compilatore le indicizzerà a partire dallo stack pointer). Questo è vero anche per l'espressione di g. La forma della chiamata ad f( ) dipenderà dallo schema di decorazione dei nomi, e "register a" dipende da come sono chiamati i registri della CPU all'interno dell'assembler della macchina. La logica che c'è dietro, comunque, rimane la stessa.

In C e C++, dapprima vengono messi sullo stack gli argomenti, da destra a sinistra, e poi viene effettuata la chiamata alla funzione. Il codice di chiamata è responsabile della cancellazione degli argomenti dallo stack (cosa che giustifica l'istruzione add sp,4). Ma bisogna notare che per passare gli argomenti per valore, il compilatore ne mette semplicemente una copia sullo stack – esso ne conosce le dimensioni e quindi il push di questi argomenti ne produce una copia accurata.

Il valore di ritorno di f( ) è messo in un registro. Di nuovo, il compilatore conosce tutto ciò che c'è da conoscere riguardo al tipo di valore da restituire, in quanto questo tipo è incorporato nel linguaggio e il compilatore lo può restituire mettendolo in un registro. Con i tipi primitivi del C, il semplice atto di copiare i bit del valore è equivalente a copiare l'oggetto.

 

Passare & ritornare oggetti grandi

Ma adesso consideriamo i tipi definiti dall'utente. Se creiamo una classe e vogliamo passare un oggetto di questa classe per valore, come possiamo supporre che il compilatore sappia cosa fare? Questo non è un tipo incorporato nel compilatore, ma un tipo che abbiamo creato noi.

Per investigare sul problema, possiamo iniziare con una semplice struttura che è chiaramente troppo grande per essere restituita in un registro:

//: C11:PassingBigStructures.cpp
struct Big {
  char buf[100];
  int i;
  long d;
} B, B2;

Big bigfun(Big b) {
  b.i = 100; // Opera sull'argomento
  return b;
}

int main() {
  B2 = bigfun(B);
} ///:~

Decodificare l'output in assembly in questo caso è un pò più complicato perchè molti compilatori usano funzioni di "appoggio" invece di mettere tutte le funzionalità inline. Nel main( ), la chiamata a bigfun ( ) parte come possiamo immaginare – l'intero contenuto di B è messo sullo stack. (Qui alcuni compilatori caricano dei registri con l'indirizzo di Big e la sua dimensione e poi chiamano una funzione di appoggio per mettere Big sullo stack.)

Nel frammento di codice precedente, mettere gli argomenti sullo stack era tutto ciò che era richiesto prima della chiamata alla funzione. In PassingBigStructures.cpp, tuttavia, possiamo vedere un'azione in più: prima di effettuare la chiamata viene fatto il push dell'indirizzo di B2 anche se è ovvio che non è un argomento. Per capire cosa succede qui, è necessario capire quali sono i vincoli del compilatore quando effettua una chiamata ad una funzione.

Struttura di stack in una chiamata a funzione

Quando il compilatore genera codice per una chiamata a funzione, prima mette gli argomenti sullo stack e poi effettua la chiamata. All'interno della funzione viene generato del codice per far avanzare lo stack pointer anche più di quanto necessario per fornire spazio alle variabili locali della funzione. ("Avanzare" può significare spostare in giù o in su a seconda della macchina.) Ma durante una CALL in linguaggio assembly  la  CPU fa il push dell'indirizzo del codice di programma da cui la funzione è stata chiamata, così l'istruzione assembly RETURN può usare questo indirizzo per ritornare nel punto della chiamata. Questo indirizzo naturalmente è sacro, perchè senza di esso il programma viene completamente perso. Qui viene mostrato come potrebbe apparire la struttura di stack dopo una CALL e l'allocazione di variabili locali in una funzione:

Argomenti della funzione
Indirizzo di ritorno
Variabili locali

Il codice generato per il resto della funzione si aspetta che la memoria sia srutturata esattamente in questo modo, in modo tale che esso può tranquillamente pescare tra gli argomenti della funzione e le variabili locali senza toccare l'indirizzo di ritorno. Chiameremo questo blocco di memoria, che contiene tutto ciò che viene usato dalla funzione durante il processo di chiamata,  struttura di funzione (function frame).

Possiamo pensare che è ragionevole tentare di restituire i valori attraverso lo stack. Il compilatore dovrebbe semplicemente metterli sullo stack, e la funzione dovrebbe restituire un offset  per indicare la posizione nello stack da cui inizia il valore di ritorno.

Ri-entranza

Il problema sussiste perchè le funzioni in C e C++ supportano gli interrupt; cioè i linguaggi sono ri-entranti. Essi supportano anche le chiamate ricorsive alle funzioni. Questo significa che in qualsiasi punto dell'esecuzione del programma può arrivare un interrupt, senza interrompere il programma. Naturalmente la persona che scrive la routine di gestione dell'interrupt (interrupt service routine, ISR) è responsabile del salvataggio e del ripristino di tutti i registri usati nella ISR, ma se l'ISR ha bisogno di ulteriore memoria sullo stack, questo deve essere fatto in maniera sicura. (Si può pensare ad un ISR come ad una normale funzione senza argomenti e con un valore di ritorno void che salva e ripristina lo stato della CPU. La chiamata ad una ISR è scatenata da qualche evento hardware piuttosto che da una chiamata esplicita all'interno del programma.)

Adesso proviamo ad immaginare cosa potrebbe succedere se una funzione ordinaria provasse a restituire un valore attraverso lo stack. Non si può toccare nessuna parte dello stack che si trova al di sopra dell'indirizzo di ritorno, così la funzione dovrebbe mettere i valori al di sotto dell'indirizzo di ritorno. Ma quando viene eseguita l'istruzione assembly RETURN  lo stack pointer deve puntare all'indirizzo di ritorno (o al di sotto di esso, dipende dalla macchina), così appena prima del RETURN la funzione deve spostare in su lo stack pointer, tagliando fuori in questo modo tutte le variabili locali. Se proviamo a ritornare dei valori sullo stack al di sotto dell'indirizzo di ritorno, è proprio in questo momento che diventiamo vulnerabili, perchè potrebbe arrivare un interrupt. L' ISR sposterà in avanti lo stack pointer per memorizzare il suo indirizzo di ritorno e le sue variabili locali e così sovrascrive il nostro valore di ritorno.

Per risolvere questo problema il chiamante dovrebbe essere responsabile dell'allocazione di uno spazio extra sullo stack per il valore di ritorno, prima di chiamare la funzione. Tuttavia, il C non è stato progettato per fare questo, e il C++  deve mantenere la compatibilità. Come vedremo brevemente,  il compilatore C++ usa uno schema molto più efficiente.

Un'altra idea potrebbe essere quella di restituire il valore in qualche area dati globale, ma anche questa non può funzionare. Rientranza vuol dire che qualunque funzione può essere una routine di interrupt per qualunque altra funzione, inclusa la funzione corrente. Così, se mettiamo il valore di ritorno in un'area globale, possiamo ritornare nella stessa funzione, che a questo punto sovrascrive il valore di ritorno. La stessa logica si applica alla ricorsione.

L'unico posto sicuro dove restituire i valori sono i registri, così siamo di nuovo al problema di cosa fare quando i registri non sono grandi abbastanza per memorizzare il valore di ritorno. La risposta è nel mettere sullo stack come un argomento della funzione l'indirizzo dell'area di destinazione del valore di ritorno, e far si che la funzione copi le informazioni di ritorno direttamente nell'area di destinazione. Questo non solo risolve tutti i problemi, ma è molto efficiente. Questo è anche il motivo per cui, in PassingBigStructures.cpp, il compilatore fa il push dell'indirizzo di B2 prima di chiamare bigfun( ) nel main( ). Se guardiamo all'output in assembly di bigfun( ), possiamo vedere che essa si aspetta questo argomento nascosto e ne effettua la copia nell'area di destinazione all'interno della funzione.

copia di bit contro inizializzazione

Fin qui tutto bene. Abbiamo una strada percorribile per il passaggio e il ritorno di grosse, ma semplici, strutture. Ma va notato che tutto quello che abbiamo è un modo per copiare bit da una parte all'altra della memoria, cosa che funziona bene certamente per il modo primitivo in cui il C vede le variabili. Ma in C++ gli oggetti possono essere molto più sofisticati di un mucchio di bit; essi hanno un significato. Questo significato potrebbe non rispondere bene ad una copia di bit.

Consideriamo un semplice esempio: una classe che sa quanti oggetti del suo tipo ci sono in ogni istante. Dal Capitolo 10 sappiamo che per fare questo si include un membro dati static:

//: C11:HowMany.cpp
// Una classe che conta i suoi oggetti
#include <fstream>
#include <string>
using namespace std;
ofstream out("HowMany.out");

class HowMany {
  static int objectCount;
public:
  HowMany() { objectCount++; }
  static void print(const string& msg = "") {
    if(msg.size() != 0) out << msg << ": ";
    out << "objectCount = "
         << objectCount << endl;
  }
  ~HowMany() {
    objectCount--;
    print("~HowMany()");
  }
};

int HowMany::objectCount = 0;

// Passaggio e ritorno PER VALORE:
HowMany f(HowMany x) {
  x.print("x argument inside f()");
  return x;
}

int main() {
  HowMany h;
  HowMany::print("after construction of h");
  HowMany h2 = f(h);
  HowMany::print("after call to f()");
} ///:~

La classe  HowMany contiene uno static int objectCount e una funzione membro static print( ), per riportare il valore di objectCount, con un argomento opzionale per stampare un messaggio. Il costruttore incrementa il contatore ogni volta che viene creato un oggetto, e il distruttore lo decrementa.

Il risultato, tuttavia, non è quello che ci si aspetta:

after construction of h: objectCount = 1
x argument inside f(): objectCount = 1
~HowMany(): objectCount = 0
after call to f(): objectCount = 0
~HowMany(): objectCount = -1
~HowMany(): objectCount = -2

Dopo che è stato creato h, il contatore di oggetti vale 1, il che è giusto. Ma dopo la chiamata ad f( ) ci si aspetta di avere il contatore di oggetti a due, perchè viene creato anche l'oggetto h2. Invece il contatore vale zero, il che indica che qualcosa è andata terribilmente storta. Questo viene confermato dal fatto che i due distruttori alla fine portano il contatore a valori negativi, cosa che non dovrebbe mai succedere.

Guardiamo dentro f( ) cosa succede dopo che è stato passato l'argomento per valore. Il passaggio per valore significa che l'oggetto h esiste al di fuori della struttura della funzione, e che c'è un oggetto addizionale all'interno della struttura della funzione, che è la copia che è stata passata per valore. Tuttavia l'argomento è stato passato usando il concetto primitivo del C della copia per bit, mentre la classe HowMany del C++ richiede una vera inizializzazione per mantenere l'integrità, perciò la semplice copia per bit fallisce nella produzione dell'effetto desiderato.

Quando la copia locale dell'oggeto esce fuori scope alla fine della chiamata ad f( ), viene chiamato il distruttore, che decrementa objectCount, per cui al di fuori della funzione objectCount vale zero. Anche la creazione di h2 viene fatta usando la copia per bit, per cui anche qui il costruttore non viene chiamato e quando h ed h2 escono fuori scope i loro distruttori producono valori negativi per objectCount.

Costruzione della copia

Il problema sussiste perchè il compilatore fa un'assunzione su come viene creato  un nuovo oggetto a partire da uno già esistente. Quando si passa un oggetto per valore si crea un nuovo oggetto, l'oggetto passato all'interno della funzione, da un oggetto esistente, cioè l'oggetto originale al di fuori della struttura della funzione. Questo spesso è vero anche quando si ritorna un oggetto all'uscita di una funzione. Nell'espressione

HowMany h2 = f(h);

h2, oggetto non preventivamente costruito, viene creato dal valore di ritorno della funzione f( ), quindi di nuovo un oggetto viene creato a partire da uno esistente.

L'assunzione del compilatore è che si vuole fare una creazione usando la copia per bit, e questo in molti casi funziona perfettamente, ma in HowMany non funziona, in quanto il significato dell'inizializzazione va al di là della semplice copia per bit. Un altro esempio comune è quello delle classi che contengono puntatori – a cosa devono puntare? Devono essere copiati? Devono essere associati a qualche altro pezzo di memoria?

Fortunatamente si può intervenire in questo processo ed evitare che il compilatore faccia una copia per bit. Questo si fa definendo la propria funzione da usare ogni volta che è necessario creare un nuovo oggetto a partire da uno esistente. Logicamente, siccome si costruisce un nuovo oggetto, questa funzione è un costruttore, e altrettanto logicamente, l'unico argomento di questo costruttore deve essere l'oggetto da cui si sta effettuando la costruzione. Ma questo oggetto non può essere passato al costruttore per valore, in quanto stiamo cercando di definire la funzione che gestisce il passaggio per valore, e sintatticamente non ha senso passare un puntatore, perchè, dopotutto, stiamo creando un nuovo oggetto da quello esistente. Qui i riferimenti ci vengono in soccorso, quindi prendiamo il riferimento all'oggetto sorgente. Questa funzione è chiamata costruttore di copia ed è spesso chiamata X(X&), se la classe è X.

Se si crea un costruttore di copia, il compilatore non effettua una copia per bit quando si crea un oggetto a partire da uno esistente. Esso chiamerà sempre il costruttore di copia. Se invece non si fornisce il costruttore di copia, il compilatore effettua comunque una copia, ma con il costruttore di copia abbiamo la possibilità di avere un controllo completo sul processo.

Adesso è possibile risolvere il problema riscontrato in HowMany.cpp:

//: C11:HowMany2.cpp
// Il costruttore di copia
#include <fstream>
#include <string>
using namespace std;
ofstream out("HowMany2.out");

class HowMany2 {
  string name; // Identificatore dell'oggetto
  static int objectCount;
public:
  HowMany2(const string& id = "") : name(id) {
    ++objectCount;
    print("HowMany2()");
  }
  ~HowMany2() {
    --objectCount;
    print("~HowMany2()");
  }
  // Il costruttore di copia:
  HowMany2(const HowMany2& h) : name(h.name) {
    name += " copy";
    ++objectCount;
    print("HowMany2(const HowMany2&)");
  }
  void print(const string& msg = "") const {
    if(msg.size() != 0) 
      out << msg << endl;
    out << '\t' << name << ": "
        << "objectCount = "
        << objectCount << endl;
  }
};

int HowMany2::objectCount = 0;

// Passaggio e ritorno PER VALORE:
HowMany2 f(HowMany2 x) {
  x.print("x argument inside f()");
  out << "Returning from f()" << endl;
  return x;
}

int main() {
  HowMany2 h("h");
  out << "Entering f()" << endl;
  HowMany2 h2 = f(h);
  h2.print("h2 after call to f()");
  out << "Call f(), no return value" << endl;
  f(h);
  out << "After call to f()" << endl;
} ///:~

Ci sono un pò di modifiche in questo codice , in modo che si possa avere un'idea più chiara di cosa succede. Prima di tutto , la stringa name agisce da identificatore quando vengono stampate informazioni relative all'oggetto. Nel costruttore, si può mettere una stringa di identificazione (generalmente il nome dell'oggetto) copiato in  name usando il costruttore di string. Il default = "" crea una stringa vuota. Il costruttore incrementa ObjectCount come prima, e il distruttore lo decrementa.

Poi c'è il costruttore di copia, HowMany2(const HowMany2&). Il costruttore di copia può creare un oggetto solo da uno già esistente, così il nome dell'oggetto esistente viene copiato in name, seguito dalla parola "copia" così si può vedere da dove viene. Se si guarda meglio, si può vedere che la chiamata name(h.name) nella lista di inizializzazione del costruttore chiama il costruttore di copia di string.

Nel costruttore di copia, il contatore di oggetti viene incrementato come nel normale costruttore. In questo modo si può ottenere un conteggio accurato degli oggetti quando c'è un passaggio e un ritorno per valore.

La funzione print( ) è stata modificata per stampare un messaggio, l'identificatore dell'oggetto e il contatore degli oggetti. Esso deve accedere ora al dato name di un particolare oggetto, perciò non può essere una funzione membro static.

All'interno del main( ), si può vedere che è stata aggiunta una seconda chiamata a f( ). Tuttavia, questa seconda chiamata usa il comune approccio del C di ignorare il valore di ritorno. Ma adesso che sappiamo come viene restituito il valore (cioè, il codice  all'interno della funzione gestisce il processo di ritorno, mettendo il risultato in un'area di destinazione il cui indirizzo viene passato come argomento nascosto), ci si potrebbe chiedere cosa succede quando il valore di ritorno viene ignorato. Il risultato del programma potrebbe fornire qualche delucidazione su questo.

Prima di mostrare il risultato, qui c'è un piccolo programma che usa iostreams per aggiungere il numero di linea ad un file:

//: C11:Linenum.cpp
//{T} Linenum.cpp
// Aggiunge i numeri di linea
#include "../require.h"
#include <vector>
#include <string>
#include <fstream>
#include <iostream>
#include <cmath>
using namespace std;

int main(int argc, char* argv[]) {
  requireArgs(argc, 1, "Usage: linenum file\n"
    "Adds line numbers to file");
  ifstream in(argv[1]);
  assure(in, argv[1]);
  string line;
  vector<string> lines;
  while(getline(in, line)) // Legge un intero file nella stringa line
    lines.push_back(line);
  if(lines.size() == 0) return 0;
  int num = 0;
  // Il numero di linee nel file determina la larghezza:
  const int width = 
    int(log10((double)lines.size())) + 1;
  for(int i = 0; i < lines.size(); i++) {
    cout.setf(ios::right, ios::adjustfield);
    cout.width(width);
    cout << ++num << ") " << lines[i] << endl;
  }
} ///:~

L'intero file viene letto dentro vector<string>, usando lo stesso codice già visto precedentemente in questo libro. Quando si stampano i numeri di linea, si vorrebbero avere tutte le linee allineate l'una all'altra, e questo richiede di aggiustare i numeri di linea nel file in modo che la larghezza permessa per i numeri di linea sia coerente. Possiamo facilmente calcolare il  numero di una linea usando vector::size( ), ma quello di cui abbiamo veramente bisogno è sapere se ci sono più di 10 linee, 100 linee, 1,000 linee, ecc. Se prendiamo il logaritmo, base 10, del numero delle linee nel file, lo tronchiamo ad un int ed aggiungiamo uno al valore, calcoliamo la larghezza massima che deve assumere il contatore di linee.

Possiamo notare una coppia di strane chiamate all'interno del ciclo for: setf( ) e width( ). Queste sono chiamate ostream che permettono di controllare, in questo caso, la giustificazione e la larghezza dell'output. Ma queste devono essere chiamate ogni volta che viene stampata una linea ed è per questo che sono messe dentro un ciclo for. Il volume 2 di questo libro contiene un capitolo intero che spiega gli iostreams e dice molte più cose riguardo a queste chiamate, come pure su altri modi di controllare iostreams.

Quando Linenum.cpp viene applicato a HowMany2.out, il risultato è

 1) HowMany2()
 2)   h: objectCount = 1
 3) Entering f()
 4) HowMany2(const HowMany2&)
 5)   h copy: objectCount = 2
 6) x argument inside f()
 7)   h copy: objectCount = 2
 8) Returning from f()
 9) HowMany2(const HowMany2&)
10)   h copy copy: objectCount = 3
11) ~HowMany2()
12)   h copy: objectCount = 2
13) h2 after call to f()
14)   h copy copy: objectCount = 2
15) Call f(), no return value
16) HowMany2(const HowMany2&)
17)   h copy: objectCount = 3
18) x argument inside f()
19)   h copy: objectCount = 3
20) Returning from f()
21) HowMany2(const HowMany2&)
22)   h copy copy: objectCount = 4
23) ~HowMany2()
24)   h copy: objectCount = 3
25) ~HowMany2()
26)   h copy copy: objectCount = 2
27) After call to f()
28) ~HowMany2()
29)   h copy copy: objectCount = 1
30) ~HowMany2()
31)   h: objectCount = 0

Come ci si potrebbe aspettare, la prima cosa che succede è che viene chiamato il costruttore normale per h, che incrementa il contatore di oggetti a uno. Ma dopo, quando si entra in f( ), il costruttore di copia viene chiamato in modo trasparente dal compilatore per effettuare il passaggio per valore. Viene creato un nuovo oggetto, che è la copia di h (da cui il nome "h copy") all'interno della struttura di f( ), così il contatore di oggetti diventa due,  grazie al costruttore di copia.

La linea otto indica l'inizio del ritorno da f( ). Ma prima che la variabile locale "h copy" possa essere distrutta (essa esce fuori scope alla fine della funzione), deve essere copiata nel valore di ritorno, che è h2. L'oggetto (h2), non ancora costruito, viene creato a partire da un oggetto già esistente (la variabile locale dentro f( )), perciò il costruttore di copia viene di nuovo usato alla linea nove. Adesso il nome dell'identificatore di  h2 diventa "h copy copy" , in quanto esso viene copiato dalla copia locale ad f( ). Dopo che l'oggetto è stato restituito, ma prima di uscire dalla funzione, il contatore di oggetti diventa temporaneamente tre, ma dopo l'oggetto locale "h copy" viene distrutto. Dopo che la chiamata ad f( ) è stata completata, alla linea 13, ci sono solo due oggetti, h e h2, e si può vedere che h2 finisce per essere la "copia della copia di h."

Oggetti temporanei

La linea 15 inizia la chiamata ad f(h), ignorando questa volta il valore di ritorno. Si può vedere alla linea 16 che il costruttore di copia viene chiamato esattamente come prima per passare l'argomento. E, come prima, la linea 21 mostra come il costruttore di copia viene chiamato per il valore di ritorno. Ma il costruttore di copia deve avere un indirizzo su cui lavorare come sua destinazione (un puntatore this). Da dove viene questo indirizzo?

Succede che il compilatore può creare un oggetto temporaneo ogni qualvolta ne ha bisogno per valutare opportunamente un'espressione. In questo caso ne crea uno che noi non vediamo che funge da destinazione per il valore di ritorno, ignorato, di f( ). La durata di questo oggetto temporaneo è la più breve possibile in modo tale che non ci siano in giro troppi oggetti temporanei in attesa di essere distrutti e che impegnano risorse preziose. In alcuni casi l'oggetto temporaneo può essere passato immediatamente ad un'altra funzione, ma in questo caso esso non è  necessario dopo la chiamata alla funzione e quindi non appena la funzione termina, con la chiamata al distruttore dell'oggetto locale (linee 23 e 24), l'oggetto temporaneo viene distrutto (linee 25 e 26).

Infine, alle linee 28-31, l'oggetto h2 viene distrutto, seguito da h, e il contatore di oggetti torna correttamente a zero.

Costruttore di copia di default

Siccome il costruttore di copia implementa il passaggio e il ritorno per valore, è importante che il compilatore ne crei uno di default nel caso di semplici strutture – che poi è la stessa cosa che fa in C. Tuttavia, tutto quello che abbiamo visto finora è il comportamento di default primitivo: una copia per bit.

Quando sono coinvolti tipi più complessi, il compilatore C++ deve comunque creare automaticamente un costruttore di copia di default. Ma, di nuovo, un copia per bit non ha senso, perchè potrebbe non  implementare il  significato corretto.

Qui c'è un esempio che mostra un approccio più intelligente che può avere il compilatore. Supponiamo di creare una nuova classe composta di oggetti di diverse classi già esistenti. Questo viene chiamato, in modo abbastanza appropriato, composizione, ed è uno dei modi per costruire nuove classi a partire da classi già esistenti. Adesso mettiamoci nei panni di un utente inesperto che vuole provare a risolvere velocemente un problema creando una nuova classe in questo modo. Non sappiamo nulla riguardo al costruttore di copia, pertanto non lo creiamo. L'esempio mostra cosa fa il compilatore mentre crea un costruttore di copia di default per questa nostra nuova classe:

//: C11:DefaultCopyConstructor.cpp
// Creazione automatica del costruttore di copia
#include <iostream>
#include <string>
using namespace std;

class WithCC { // Con costruttore di copia
public:
  // Richiesto il costruttore di default esplicito:
  WithCC() {}
  WithCC(const WithCC&) {
    cout << "WithCC(WithCC&)" << endl;
  }
};

class WoCC { // Senza costruttore di copia
  string id;
public:
  WoCC(const string& ident = "") : id(ident) {}
  void print(const string& msg = "") const {
    if(msg.size() != 0) cout << msg << ": ";
    cout << id << endl;
  }
};

class Composite {
  WithCC withcc; // Oggetti incorporati
  WoCC wocc;
public:
  Composite() : wocc("Composite()") {}
  void print(const string& msg = "") const {
    wocc.print(msg);
  }
};

int main() {
  Composite c;
  c.print("Contents of c");
  cout << "Calling Composite copy-constructor"
       << endl;
  Composite c2 = c;  // Chiama il costruttore di copia
  c2.print("Contents of c2");
} ///:~

La classe WithCC contiene un costruttore di copia, che annuncia semplicemente che è stato chiamato, e questo solleva una questione interessante. Nella classe Composite, viene creato un oggetto di tipo WithCC usando un costruttore di default. Se non ci fosse stato per niente il costruttore nella classe  WithCC, il compilatore ne avrebbe creato automaticamente uno di default, che in questo caso non avrebbe fatto nulla. Ma se aggiungiamo un costruttore di copia, diciamo al compilatore che ci accingiamo a gestire la creazione del costruttore ed esso non ne creerà uno per noi e darà errore, a meno che non gli diciamo esplicitamente di crearne uno di default, come è stato fatto per WithCC.

La classe WoCC non ha un costruttore di copia, ma memorizza un messaggio in una stringa interna che può essere stampata con print( ). Questo costruttore viene esplicitamente chiamato nella lista di inizializzatori del costruttore di  Composite (brevemente introdotta nel Capitolo 8 e completamente coperta nel Capitolo 14). La ragione di ciò sarà chiara più avanti.

La classe Composite ha oggetti membro sia di tipo WithCC che WoCC (notare che l'oggetto incorporato WoCC viene inizializzato nella lista di inizializzatori del costruttore, come deve essere), e non ha un costruttore di copia esplicitamente definito. Tuttavia, in main( ) viene creato un oggetto usando il costruttore di copia nella definizione:

Composite c2 = c;

Il costruttore di copia di Composite viene creato automaticamente dal compilatore, e l'output del programma rivela il modo in cui ciò avviene:

Contents of c: Composite()
Calling Composite copy-constructor
WithCC(WithCC&)
Contents of c2: Composite()

Per creare un costruttore di copia una classe che usa la composizione (e l'ereditarietà, che è introdotta nel Capitolo 14), il compilatore chiama ricorsivamente i costruttori di copia per tutti gli oggetti membri e per le classi base. Cioè, se l'oggetto membro contiene un altro oggetto, anche il suo costruttore di copia viene chiamato. Così, in questo caso il compilatore chiama il costruttore di copia per WithCC. L'output mostra che questo costruttore viene chiamato. Siccome WoCC non ha un costruttore di copia, il compilatore ne crea uno che effettua semplicemente la copia per bit, e lo chiama all'interno del costruttore di copia di Composite. La chiamata a Composite::print( ) in main mostra che questo succede in quanto i contenuti di  c2.WoCC sono identici ai contenuti di c.WoCC. Il processo che il compilatore mette in atto per sintetizzare un costruttore di copia è detto inizializzazione per membro (memberwise initialization) .

È sempre meglio creare il proprio costruttore di copia, invece di farlo fare al compilatore. Questo garantisce che starà sotto il nostro controllo.

Alternative alla costruzione della copia

A questo punto forse vi gira un pò la testa e vi state meravigliando di come sia stato possibile scrivere finora codice funzionante senza sapere nulla riguardo al costruttore di copia. Ma ricordate: abbiamo bisogno di un costruttore di copia solo se dobbiamo passare un oggetto per valore. Se questo non succede, non abbiamo bisogno di un costruttore di copia.

Prevenire il passaggio-per-valore

"Ma," potreste dire, "se non forniamo un costruttore di copia, il compilatore ne creerà uno per noi. E come facciamo a sapere che un oggetto non sarà mai passato per valore?"

C'è una tecnica molto semplice per prevenire il passaggio per valore: dichiarare un costruttore di copia private. Non c'è bisogno di fornire una definizione, a meno che una delle funzioni membro o una funzione friend non abbiano bisogno di effettuare un passaggio per valore. Se l'utente prova a passare o a ritornare un oggetto per valore, il compilatore da un messaggio di errore, in quanto il costruttore di copia è private. Inoltre esso non creerà un costruttore di copia di default, in quanto gli abbiamo detto esplicitamente che abbiamo noi il controlo su questo.

Qui c'è un esempio:

//: C11:NoCopyConstruction.cpp
// Prevenire la costruzione della copia

class NoCC {
  int i;
  NoCC(const NoCC&); // Nessuna definizione
public:
  NoCC(int ii = 0) : i(ii) {}
};

void f(NoCC);

int main() {
  NoCC n;
//! f(n); // Errore: chiamato il costruttore di copia
//! NoCC n2 = n; // Errore: chiamato c-c
//! NoCC n3(n); // Errore: chiamato c-c
} ///:~

Notare l'uso della forma molto più generale

NoCC(const NoCC&);

che fa uso di const.

Funzioni che modificano oggetti esterni

La sintassi dei riferimenti è più accurata di quella dei puntatori, ma nasconde il significato al lettore. Per esempio, nella libreria iostreams una versione overloaded della funzione get( ) prende come argomento un char&, e lo scopo della funzione è proprio quello di modificare l'argomento per inserire il risultato della get( ). Ma quando si legge del codice che usa questa funzione, non è immediatamente ovvio che l'oggetto esterno viene modificato:

char c;
cin.get(c); 

In effetti la chiamata alla funzione fa pensare ad un passaggio per valore e quindi suggerisce che l'oggetto esterno non viene modificato.

Per questo motivo, probabilmente è più sicuro da un punto di vista della manutenzione del codice usare i puntatori quando si deve passare l'indirizzo di un argomento che deve essere modificato. Se un indirizzo lo si passa sempre come riferimento const eccetto quando si intende modificare l'oggetto esterno attraverso l'indirizzo, caso in cui si usa un puntatore non-const, allora il codice diventa molto più facile da seguire per un lettore.

Puntatori a membri

Un puntatore è una variabile che memorizza l'indirizzo di qualche locazione di memoria. Si può cambiare a runtime quello che il puntatore seleziona e la destinazione di un puntatore può essere sia un dato sia una funzione. Il  puntatore-a-membro del C++ segue lo stesso principio, solo che seleziona una locazione all'interno di una classe. Il dilemma qui è che il puntatore ha bisogno di un indirizzo, ma non ci sono "indirizzi" dentro una classe; selezionare un membro di una classe significa prenderne l'offset all'interno della classe. Non si può produrre un indirizzo prima di comporre tale offset con l'indirizzo di inizio di un particolare oggetto. La sintassi dei puntatori a membri richiede di selezionare un oggetto nel momento stesso in cui viene dereferenziato un puntatore a membro.

Per capire questa sintassi, consideriamo una semplice struttura, con un puntatore sp e un oggetto so per questa struttura. Si possono selezionare membri con la sintassi mostrata:

//: C11:SimpleStructure.cpp
struct Simple { int a; };
int main() {
  Simple so, *sp = &so;
  sp->a;
  so.a;
} ///:~

Adesso supponiamo di avere un puntatore ordinario a un intero, ip. Per accedere al valore puntato da ip si dereferenzia il puntatore con ‘*':

*ip = 4;

Infine consideriamo cosa succede se abbiamo un puntatore che punta a qualcosa all'interno di un oggetto di una classe, anche se di fatto esso rappresenta un offset all'interno dell'oggetto. Per accedere a ciò a cui punta bisogna dereferenziarlo con *. Ma si tratta di un offset all'interno dell'oggetto, e quindi bisogna riferirsi anche a quel particolare oggetto. Così l' * viene combinato con la dereferenziazione dell'oggetto. Così la nuova sintassi diventa –>* per un puntatore a un oggetto e .* per un oggetto o un riferimento, come questo:

objectPointer->*pointerToMember = 47;
object.*pointerToMember = 47;

Ora qual'è la sintassi per definire pointerToMember? Come qualsiasi puntatore, bisogna dire a quale tipo punta e usare un * nella definizione. L'unica differenza è che bisogna dire con quale classe di oggetti questo puntatore-a-membro deve essere usato. Naturalmente, questo si ottiene con il nome della classe e l'operatore di risoluzione di scope.

int ObjectClass::*pointerToMember;

definisce una variabile puntatore-a-membro chiamata pointerToMember che punta a un int all'interno di ObjectClass. Si può anche inizializzare un puntatore-a-membro quando lo si definisce (o in qualunque altro momento):

int ObjectClass::*pointerToMember = &ObjectClass::a;

Non c'è un "indirizzo" di ObjectClass::a perchè ci stiamo riferendo ad una classe e non ad un oggetto della classe. Così, &ObjectClass::a può essere usato solo come sintassi di un puntatore-a-membro.

Qui c'è un esempio che mostra come creare e usare i puntatori a dati membri:

//: C11:PointerToMemberData.cpp
#include <iostream>
using namespace std;

class Data {
public:  
  int a, b, c; 
  void print() const {
    cout << "a = " << a << ", b = " << b
         << ", c = " << c << endl;
  }
};

int main() {
  Data d, *dp = &d;
  int Data::*pmInt = &Data::a;
  dp->*pmInt = 47;
  pmInt = &Data::b;
  d.*pmInt = 48;
  pmInt = &Data::c;
  dp->*pmInt = 49;
  dp->print();
} ///:~

Ovviamente non è il caso di usare i puntatori-a-membri dappertutto, se non in casi particolari (che è il motivo esatto per cui sono stati introdotti).

Inoltre essi sono molto limitati: possono essere assegnati solo a locazioni specifiche all'interno di una classe. Non si possono, ad esempio, incrementare o confrontare come i normali puntatori.

Funzioni

In modo simile si ottiene la sintassi di un puntatore-a-membro per le funzioni membro. Un puntatore a funzione (introdotto nel Capitolo 3) è definito come questo:

int (*fp)(float);

Le parentesi intorno a (*fp) sono necessarie affinchè il compilatore valuti in modo appropriato la definizione. Senza di esse potrebbe sembrare la definizione di una funzione che restituisce un int*.

Le parentesi giocano un ruolo importante anche nella definizione e nell'uso dei punatori a funzioni membro. Se abbiamo una funzione in una classe, possiamo definire un puntatore ad essa usando il nome della classe e l'operatore di risoluzione di scope all'interno di una definizione ordinaria di puntatore a funzione:

//: C11:PmemFunDefinition.cpp
class Simple2 { 
public: 
  int f(float) const { return 1; }
};
int (Simple2::*fp)(float) const;
int (Simple2::*fp2)(float) const = &Simple2::f;
int main() {
  fp = &Simple2::f;
} ///:~

Nella definizione di fp2 si può notare che un puntatore ad una funzione membro può anche essere inizializzato quando viene creato, o in qualunque altro momento. A differenza delle funzioni non-membro, l'operatore &  non è opzionale quando si prende l'indirizzo di una funzione membro. Tuttavia, si può fornire l'identificatore della funzione senza la lista degli argomenti, perchè la risoluzione dell'overload può essere effettuata sulla base del tipo del puntatore a membro.

Un esempio

L'importanza di un puntatore sta nel fatto che si può cambiare runtime l'oggetto a cui punta, il che conferisce molta flessibiltà alla programmazione, perchè attraverso un puntatore si può selezionare o cambiare comportamento a runtime. Un puntatore-a-membro non fa eccezione; esso permette di scegliere un membro a runtime. Tipicamente, le classi hanno solo funzioni membro pubblicamente visibili  (I dati membro sono generalmente considerati parte dell'implementazione sottostante), così l'esempio seguente seleziona funzioni membro a runtime.

//: C11:PointerToMemberFunction.cpp
#include <iostream>
using namespace std;

class Widget {
public:
  void f(int) const { cout << "Widget::f()\n"; }
  void g(int) const { cout << "Widget::g()\n"; }
  void h(int) const { cout << "Widget::h()\n"; }
  void i(int) const { cout << "Widget::i()\n"; }
};

int main() {
  Widget w;
  Widget* wp = &w;
  void (Widget::*pmem)(int) const = &Widget::h;
  (w.*pmem)(1);
  (wp->*pmem)(2);
} ///:~

Naturalmente non è particolarmente ragionevole aspettarsi che un utente qualsiasi possa creare espressioni così complicate. Se un utente deve manipolare direttamente un puntatore-a-membro, allora un  typedef è quello che ci vuole. Per rendere le cose veramente pulite si possono usare i puntatori-a-membri come parte del meccanismo di implementazione interna. Qui c'è l'esempio precedente che usa un puntatore-a-membro all'interno della classe. Tutto quello che l'utente deve fare è passare un numero per selezionare una funzione.[48]

 

//: C11:PointerToMemberFunction2.cpp
#include <iostream>
using namespace std;

class Widget {
  void f(int) const { cout << "Widget::f()\n"; }
  void g(int) const { cout << "Widget::g()\n"; }
  void h(int) const { cout << "Widget::h()\n"; }
  void i(int) const { cout << "Widget::i()\n"; }
  enum { cnt = 4 };
  void (Widget::*fptr[cnt])(int) const;
public:
  Widget() {
    fptr[0] = &Widget::f; // Richiesta specificazione completa
    fptr[1] = &Widget::g;
    fptr[2] = &Widget::h;
    fptr[3] = &Widget::i;
  }
  void select(int i, int j) {
    if(i < 0 || i >= cnt) return;
    (this->*fptr[i])(j);
  }
  int count() { return cnt; }
};

int main() {
  Widget w;
  for(int i = 0; i < w.count(); i++)
    w.select(i, 47);
} ///:~

Nell'interfaccia della classe e nel main( ), si può vedere che l'intera implementazione, comprese le funzioni, è stata nascosta. Il codice deve sempre invocare count( ) per selezionare una funzione. In questo modo l'implementatore della classe può cambiare il numero di funzioni nell'implementazione sottostante, senza influire sul codice in cui la classe viene usata.

L'inizializzazione dei puntatori-a-membri nel costruttore può sembrare sovraspecificato. Non si potrebbe semplicemente scrivere

fptr[1] = &g;

visto che il nome g appare nella funzione membro, che è automaticamente nello scope della classe? Il problema è che questo non è conforme alla sintassi del puntatore-a-membro, che invece è richiesta, così tutti, specialmente il compilatore, possono sapere cosa si sta facendo. Allo stesso modo, quando un puntatore-a-membro viene dereferenziato, assomiglia a

(this->*fptr[i])(j);

che pure è sovraspecificato; this sembra ridondante. Di nuovo, la sintassi richiede che un puntatore-a-membro sia sempre legato a un oggetto quando viene dereferenziato.

Sommario

I puntatori in C++ sono quasi identici ai puntatori in C, il che è buono. Altrimenti un sacco di codice scritto in C non si compilerebbe in C++. Gli unici errori a compile-time che vengono fuori sono legati ad assegnamenti pericolosi. Se proprio si vuole cambiare il tipo, bisogna usare esplicitamente il cast.

Il C++ aggiunge anche i riferimenti, presi dall'Algol e dal Pascal, che sono come dei puntatori costanti automaticamente dereferenziati dal compilatore. Un riferimento contiene un indirizzo, ma lo si può trattare come un oggetto. I riferimenti sono essenziali per una sintassi chiara con l'overload degli operatori (l'argomento del prossimo capitolo), ma essi aggiungono anche vantaggi sintattici nel passare e ritornare oggetti per funzioni ordinarie.

Il costruttore di copia prende un riferimento ad un oggetto esistente dello stesso tipo come argomento e viene usato per creare un nuovo oggetto da uno esistente. Il compilatore automaticamente chiama il costruttore di copia quando viene passato o ritornato un oggetto per valore. Anche se il compilatore è in grado di creare un costruttore di copia di default, se si pensa che ne serve uno per la nostra classe è meglio definirlo sempre in proprio per assicurare il corretto funzionamento. Se non si vuole che gli oggetti siano passati o ritornati per valore, si può creare un costruttore di copia private.

I puntatori-a-membri hanno le stesse funzionalità dei puntatori ordinari: si può scegliere una particolare zona di memoria  (dati o funzioni) a runtime. I puntatori-a-membri semplicemente funzionano con i membri di classi invece che con dati globali o funzioni. Si ottiene la flessibilità di programmazione che permette di cambiare il comportamento a runtime.

Esercizi

Le soluzioni agli esercizi selezionati possono essere trovate nel documento elettronico The Thinking in C++ Annotated Solution Guide, disponibile dietro un piccolo compenso su www.BruceEckel.com.

  1. Tradurre il frammento di codice "bird & rock" introdotto all'inizio di questo capitolo in un programma C (usando le structs per i tipi di dati), e mostrare che si compila. Adesso provare a compilarlo con il compilatore C++ e vedere cosa succede.
  2. Prendere il frammento di codice presentato all'inizio della sezione "Riferimenti in C++" e metterlo dentro un main( ). Aggiungere istruzioni per stampare, in modo da provare che i riferimenti funzionano come dei puntatori che vengono automaticamente dereferenziati.
  3. Scrivere un programma in cui si prova a (1) Creare un riferimento che non viene inizializzato all'atto della creazione. (2) Cambiare un riferimento in modo che si riferisca ad un altro oggetto dopo l'inizializzaizione. (3) Creare un riferimento NULL.
  4. Scrivere una funzione che prende un puntatore come argomento, modifica il valore puntato, e poi restituisce la destinazione del puntatore come riferimento.
  5. Creare una classe con alcune funzioni membro, e costruire un oggetto di questa classe e usarlo come oggetto puntato dall'argomento dell'Esercizio 4. Rendere questo puntatore un const e definire const alcune delle funzioni membro e  provare che si possono chiamare solo le funzioni const all'interno della funzione dell'Esercizio 4. Cambiare l'argomento della funzione da puntatore a riferimento.
  6. Prendere il frammento di codice all'inizio della sezione "Riferimenti a puntatori" e tradurlo in un programma.
  7. Creare una funzione che prene un argomento di riferimento a un puntatore e lo modifica. Chiamare la funzione nel main( ).
  8. Creare una funzione che prende argomento char& e lo modifica. Nel main( ), stampare una variabile char, chiamare la funzione per questa variabile e stamparla di nuovo per verificare che è stata modificata. Che impatto ha questo sulla leggibilità del programma?
  9. Scrivere una classe che ha una funzione membro const e una funzione membro non-const. Scrivere tre funzioni che prendono come argomento un oggetto di questa classe; la prima lo prende per valore, la seconda per riferimento e la terza per riferimento const . All'interno delle funzioni provare a chiamare entrambe le funzioni membro della classe e spiegare i risultati.
  10. (Piuttosto impegnativo) Scrivere una semplice funzione che prende come argomento un int, ne incrementa il valore e lo restituisce. Chiamare questa funzione nel main( ). Adesso scoprire come il compilatore genera codice assembly e scorrere tale codice per capire come sono stati passati e restituiti gli argomenti e come le variabili locali sono state indicizzate all'interno dello stack.
  11. Scrivere una funzione che prende come argomenti un char, un int, un float e un double. Generare codice assembly con il compilatore e cercare le istruzioni che mettono gli argomenti sullo stack prima della chiamata alla funzione.
  12. Scrivere una funzione che restituisce un double. Generare codice assembly e vedere come vengono restituiti i valori.
  13. Produrre codice assembly per PassingBigStructures.cpp. Scorrere il codice e scoprire come il compilatore genera codice per passare e restituire grosse strutture.
  14. Scrivere una semplice funzione ricorsiva che decrementa il suo argomento e restituisce zero se l'argomento diventa zero. Generare codice assembly per questa funzione e spiegare come il modo in cui il codice assembly è stato generato dal compilatore supporta la ricorsione.
  15. Scrivere del codice per dimostrare che il compilatore sintetizza automaticamente un costruttore di copia se non se ne fornisce uno in proprio. Provare che il costruttore di copia così sintetizzato effettua una copia per bit per i tipi primitivi (predefiniti) e chiama il costruttore di copia dei tipi definiti dall'utente.
  16. Scrivere una classe con un costruttore di copia che annuncia se stesso a cout. Adesso creare una funzione che passa un oggetto della nuova classe per valore e un'altra funzione che crea un oggetto locale della nuova classe e lo restituisce per valore. Chiamare queste funzioni per provare che il costruttore di copia viene chiamato in modo trasparente quando vengono passati o restituiti oggetti per valore.
  17. Creare una classe che contiene un double*. Il costruttore inizializza il double* chiamando new double e assegnando il valore del suo argomento alla memoria restituita . Il distruttore stampa il valore puntato, gli assegna il valore –1, chiama delete per la memoria allocata, e poi azzera il puntatore. Adesso creare una funzione che prende un oggetto della classe per valore e chiamare questa funzione nel main( ). Cosa succede? Rimediare al problema scrivendo un costruttore di copia.
  18. Creare una classe con un costruttore che si presenta come un costruttore di copia, ma ha un argomento extra con un valore di default. Mostrare che questo è comunque usato come costruttore di copia.
  19. Creare una classe con un costruttore di copia che annuncia se stesso. Costruire un'altra classe che contiene un oggetto membro della prima classe, ma non creare un costruttore di copia. Dimostrare che il costruttore di copia automaticamente sintetizzato dal compilatore nella seconda classe chiama il costruttore di copia della prima classe.
  20. Creare una classe molto semplice e una funzione che restituisce un oggetto della classe per valore. Creare una seconda funzione che prende come argomento un riferimento ad un oggetto della classe. Chiamare la prima funzione come argomento della seconda funzione e dimostrare che la seconda funzione deve usare un riferimento const come suo argomento.
  21. Creare una semplice classe senza costruttore di copia e una semplice funzione che prende come argomento un oggetto della classe per valore. Adesso cambiare la classe aggiungendo (solo) una dichiarazione private per il costruttore di copia. Spiegare cosa succede quando la funzione viene compilata.
  22. Questo esercizio crea un'alternativa all'uso del costruttore di copia. Creare una classe X e dichiarare (ma non definire) un costruttore di copia private. Costruire una funzione public, di nome clone( ), come funzione membro const che restituisce una copia dell'oggetto creato usando new. Adesso scrivere una funzione che prende come argomento un const X& e clona una copia locale che può essere modificata. Lo svantaggio di questo approccio è che siamo responsabili della distruzione esplicita dell'oggetto clonato (usando delete) quando abbiamo finito di lavorarci.
  23. Spiegare cosa c'è di sbagliato in Mem.cpp e MemTest.cpp dal Capitolo 7. Rimediare al problema.
  24. Creare una classe contenente un double e una funzione print( ) che stampa il double. Nel main( ), creare puntatori a membri sia per il  dato che per la funzione, membri della classe. Creare un oggetto della classe e un puntatore ad esso e manipolare entrambi i membri attraverso i puntatori a memmbri, usando sia l'oggetto che il puntatore all'oggetto.
  25. Creare una classe contenente un array di int. Si può scorrere l'array usando un puntatore a membro?
  26. Modificare PmemFunDefinition.cpp aggiungendo una funzione overloaded f( ) (si possono determinare gli argomenti che causano l'verloading). Adesso costruire un secondo puntatore a membro, assegnarlo alla versione overloaded di f( ) e chiamare la funzione tramite questo puntatore. A cosa da luogo la risoluzione di overload in questo caso?
  27. Partire da FunctionTable.cpp del Capitolo 3. Creare una classe che contiene un vector di puntatori a funzione, con funzioni membro add( ) e remove( ) per aggiungere e rimuovere puntatori a funzioni. Aggiungere una funzione run( ) che scorre il vector e chiama tutte le funzioni.
  28. Modificare l'esercizio 27 di sopra affinchè lavori con puntatori a funzioni membro.

 


[48] Grazie a Mortensen per questo esempio

[ Capitolo Precedente ] [ Indice Generale ] [ Indice Analitico ] [ Prossimo Capitolo ]
Ultima modifica:24/12/2002