Uso dei puntatori
I puntatori sono utilizzati sostanzialmente
per quattro scopi:
- Realizzazione di strutture dati dinamiche (es. liste linkate);
- Realizzazione di funzioni con effetti laterali sui parametri attuali;
- Ottimizzare il passaggio di parametri di grosse dimensioni;
- Rendere possibile il passaggio di parametri di tipo funzione.
Il primo caso e` tipico di applicazioni per le quali non e` noto a
priori la quantita` di dati che si andranno a manipolare. Senza i
puntatori non sarebbe possibile manipolare contemporaneamente un numero
non predefinito di dati, anche utilizzando un array porremmo un limite
massimo al numero di oggetti di un certo tipo immediatamente disponibili.
Utilizzando i puntatori invece e` possibile realizzare ad esempio una
lista il cui numero massimo di elementi non e` definito a priori:
|
#include < iostream >
using namespace std;
// Una lista e` composta da tante celle linkate
// tra di loro; ogni cella contiene un valore
// e un puntatore alla cella successiva.
struct TCell {
float AFloat; // per memorizzare un valore
TCell* Next; // puntatore alla cella successiva
};
// La lista viene realizzata tramite questa
// struttura contenente il numero corrente di celle
// della lista e il puntatore alla prima cella
struct TList {
unsigned Size; // Dimensione lista
TCell* First; // Puntatore al primo elemento
};
int main(int, char* []) {
TList List; // Dichiara una lista
List.Size = 0; // inizialmente vuota
int FloatToRead;
cout << "Quanti valori vuoi immettere? " ;
cin >> FloatToRead;
cout << endl;
// questo ciclo richiede valori reali
// e li memorizza nella lista
for(int i=0; i < FloatToRead; ++i) {
TCell* Temp = List.First;
cout << "Creazione di una nuova cella..." << endl;
List.First = new TCell; // new vuole il tipo di
// variabile da creare
cout << "Immettere un valore reale " ;
// cin legge l'input da tastiera e l'operatore di
// estrazione >> lo memorizza nella variabile.
cin >> List.First -> AFloat;
cout << endl;
List.First -> Next = Temp; // aggiunge la cella in
// testa alla lista
++List.Size; // incrementa la
// dimensione della lista
}
// il seguente ciclo calcola la somma
// dei valori contenuti nella lista;
// via via che recupera i valori,
// distrugge le relative celle
float Total = 0.0;
for(int j=0; j < List.Size; ++j) {
Total += List.First -> AFloat;
// estrae la cella in testa alla lista...
TCell* Temp = List.First;
List.First = List.First -> Next;
// e quindi la distrugge
cout << "Distruzione della cella estratta..."
<< endl;
delete Temp;
}
cout << "Totale = " << Total << endl;
return 0;
}
|
Il programma sopra riportato programma memorizza in una lista un certo numero
di valori reali, aggiungendo per ogni valore una nuova cella; in seguito li
estrae uno ad uno e li somma restituendo il totale; via via che un
valore viene estratto dalla lista, la cella corrispondente viene distrutta.
Il codice e` ampiamente commentato e non dovrebbe essere difficile capire
come funziona. La creazione di un nuovo oggetto avviene allocando un nuovo
blocco di memoria (sufficientemente grande) dalla heap-memory
(una porzione di memoria riservata all'avvio di un programma per
operazioni di questo tipo), mentre la distruzione avviene deallocando tale
blocco (che ritorna a far parte della heap-memory);
l'allocazione viene eseguita tramite l'operatore new cui va
specificato il tipo di oggetto da creare (per sapere quanta ram allocare), la
deallocazione avviene invece tramite l'operatore delete, che richiede
come argomento un puntatore all'aggetto da deallocare (la quantita` di ram da
deallocare viene calcolata automaticamente).
In alcuni casi e` necessario allocare e deallocare interi array, in questi
casi si ricorre agli operatori new[] e
delete[]:
|
// alloca un array di 10 interi
int* ArrayOfInt = new int[10];
// ora eseguiamo la deallocazione
delete[] ArrayOfInt;
|
La dimensione massima di strutture dinamiche e` unicamente determinata
dalla dimensione della heap memory che a sua volta e` generalmente
limitata dalla quantita` di memoria del sistema.
Un altro importante aspetto degli oggetti allocati dinamicamente e`
che essi non ubbidiscono alle normali regole di scoping statico, solo
i puntatori in quanto tali sono soggetti a tali regole, un oggetto
allocato dinamicamente puo` quindi essere creato in un certo scope
ed essere acceduto in un altro semplicemente trasmettendone l'indirizzo
(il valore del puntatore).
Consideriamo ora il secondo uso che si fa dei puntatori.
Esso corrisponde a quello che in Pascal si chiama "passaggio di parametri
per variabile" e consente la realizzazione di funzioni con effetti laterali
sui parametri attuali:
|
void Change(int* IntPtr) {
*IntPtr = 5;
}
|
La funzione Change riceve come unico parametro un
puntatore a int, ovvero un indirizzo di una cella di memoria; anche
se l'indirizzo viene copiato in una locazione di memoria visibile solo alla
funzione, la dereferenzazione di tale copia consente comunque la modifica
dell'oggetto puntato:
|
int A = 10;
cout << " A = " << A << endl;
cout << " Chiamata a Change(int*)... " << endl;
Change(&A);
cout << " Ora A = " << A << endl;
|
l'output che il precedente codice produce e`:
|
A = 10
Chiamata a Change(int*)...
Ora A = 5
|
Quello che nell'esempio accade e` che la funzione Change
riceve l'indirizzo della variabile A e tramite esso e` in
grado di agire sulla variabile stessa.
L'uso dei puntatori come parametri di funzione non e` comunque utilizzato
solo per consentire effetti laterali, spesso un funzione riceve parametri
di dimensioni notevoli e l'operazione di copia del parametro attuale in
un'area privata della funzione ha effetti deleterei sui tempi di esecuzione
della funzione stessa; in questi casi e` molto piu` conveniente passare un
puntatore che generalmente occupa pochi byte:
|
void Func(BigParam parametro);
// funziona, ma e` meglio quest'altra dichiarazione
void Func(const BigParam* parametro);
|
Il secondo prototipo e` piu` efficiente perche` evita l'overhead imposto
dal passaggio per valore, inoltre l'uso di const previene ogni
tentativo di modificare l'oggetto puntato e allo stesso tempo comunica al
programmatore che usa la funzione che non esiste tale rischio.
Infine quando l'argomento di una funzione e` un array, il compilatore passa
sempre un puntatore, mai una copia dell'argomento; in questo caso inoltre
l'unico modo che la funzione ha per conoscere la dimensione dell'array e`
quello di ricorrere ad un parametro aggiuntivo, esattamente come accade con
la funzione main() (vedi capitolo
precedente).
Ovviamente una funzione puo` restituire un tipo puntatore, in questo caso
bisogna pero` prestare attenzione a cio` che si restituisce, non e` raro
infatti che un principiante scriva qualcosa del tipo:
|
int* Sum(int a, int b) {
int Result = a + b;
return &Result;
}
|
Apparentemente e` tutto corretto e un compilatore potrebbe anche non
segnalare niente, tuttavia esiste un grave errore: si ritorna l'indirizzo
di una variabile locale. L'errore e` dovuto al fatto che la variabile
locale viene distrutta quando la funzione termina e riferire ad essa
diviene quindi illecito. Una soluzione corretta sarebbe stata quella di
allocare Result nello heap e restituire l'indirizzo di tale
oggetto (in questo caso e` cura di chi usa la funzione occuparsi della
eventuale deallocazione dell'oggetto).
Infine un uso importante dei puntatori e` per passare come parametro
un'altra funzione. Si tratta di un meccanismo che sta alla base
dei linguaggi funzionali e che permette di realizzare algoritmi generici
(anche se in C++ molte di queste cose sono spesso piu` semplici da ottenere
con i template, in alcuni casi pero` il vecchio
approccio risulta migliore):
|
#include < iostream >
using namespace std;
// Definiamo un tipo funzione:
typedef bool Eval(int, int);
bool Max(int a, int b) {
return (a>=b)? true: false;
}
bool Min(int a, int b) {
return (a<=b)? true: false;
}
// Notare il tipo del primo parametro
void Check(Eval* Func, char* FuncName,
int Param1, int Param2) {
cout << "E` vero che " << Param1 << " = " << FuncName
<< '(' << Param1 << ',' << Param2 << ") ? ";
// Utilizzo del puntatore per eseguire la chiamata
// alla funzione puntata (nella condizione dell'if)
if (Func(Param1, Param2)) cout << "Si" << endl;
else cout << "No" << endl;
}
int main(int, char* []) {
for(int i=0; i<10; ++i) {
cout << "Immetti un intero: ";
int A;
cin >> A;
cout << endl << "Immetti un altro intero: ";
int B;
cin >> B;
cout << endl;
// Si osservi il modo in cui viene
// ricavato l'indirizzo di una funzione
// (primo parametro della Check)
Check(Max, "Max", A, B);
Check(Min, "Min", A, B);
cout << endl << endl;
}
return 0;
}
|
La typedef dice che Eval e` un tipo "funzione che prende due interi e restituisce un bool", quindi conformemente al
tipo Eval definiamo due funzioni Max e
Min dall'evidente significato. Si definisce quindi
una funzione Check che riceve quattro parametri: un
puntatore a Eval, una stringa e due interi.
La funzione Check usa Func per
eseguire la chiamata alla funzione puntata e ricavarne il valore restituito. Si noti che la chiamata alla funzione puntata viene
eseguita come se Func fosse esso stesso la funzione
(ovvero utilizzando l'operatore () e passando normalmente
i parametri).
Si noti infine che la funzione main ricava l'indirizzo di
Max e Min senza ricorrere all'operatore
&, analogamente a quanto si fa con gli array.
Reference
I reference (riferimenti) sono sotto certi
aspetti un costrutto a meta` tra puntatori e le usuali variabili: come i
puntatori essi sono contenitori di indirizzi, ma non e` necessario
dereferenziarli per accedere all'oggetto puntato (si usano come se
fossero normali variabili). In pratica possiamo vedere i reference come un
meccanismo per creare alias di variabili, anche se in effetti questa e` una
definizione non del tutto esatta.
Cosi` come un puntatore viene indicato nelle dichiarazioni dal simbolo *, un reference viene indicato dal simbolo &:
|
int Var = 5;
float f = 0.5;
int* IntPtr = &Var;
int& IntRef = Var; // nei reference non serve
float& FloatRef = f; // utilizzare & a destra di =
|
Le ultime due righe dichiarano rispettivamente un riferimento a
int e uno a float che vengono subito inizializzati usando le
due variabili dichiarate prima. Un riferimento va inizializzato
immediatamente, e dopo l'inizializzazione non puo` essere piu` cambiato; si
noti che non e` necessario utilizzare l'operatore & (indirizzo
di) per eseguire l'inizializzazione.
Dopo l'inizializzazione il riferimento potra` essere utilizzato in luogo
della variabile cui e` legato, utilizzare l'uno o l'altro sara`
indifferente:
|
cout << "Var = " << Var << endl;
cout << "IntRef = " << IntRef << endl;
cout << "Assegnamento a IntRef..." << endl;
IntRef = 8;
cout << "Var = " << Var << endl;
cout << "IntRef = " << IntRef << endl;
cout << "Assegnamento a Var..." << endl;
Var = 15;
cout << "Var = " << Var << endl;
cout << "IntRef = " << IntRef << endl;
|
Ecco l'output del precedente codice:
|
Var = 5
IntRef = 5
Assegnamento a IntRef...
Var = 8
IntRef = 8;
Assegnamento a Var...
Var = 15
IntRef = 15
|
Dall'esempio si capisce perche`, dopo l'inizializzazione, un riferimento
non possa essere piu` associato ad un nuovo oggetto: ogni assegnamento al
riferimento si traduce in un assegnamento all'oggetto riferito.
Un riferimento puo` essere inizializzato anche tramite un puntatore:
|
int* IntPtr = new int(5);
// il valore tra parentesi specifica il valore cui
// inizializzare l'oggetto allocato. Per adesso il
// metodo funziona solo con i tipi primitivi.
int& IntRef = *IntPtr;
|
Si noti che il puntatore va dereferenziato, altrimenti si legherebbe il
riferimento al puntatore (in questo caso l'uso del riferimento comporta
implicitamente una conversione da int* a int).
Ovviamente il metodo puo` essere utilizzato anche con l'operatore
new:
|
double& DoubleRef = *new double;
// Ora si puo` accedere all'oggetto allocato
// tramite il riferimento.
DoubleRef = 7.3;
// Di nuovo, e` compito del programmatore
// distruggere l'oggetto crato con new
delete &DoubleRef;
// Si noti che va usato l'operatore &, per
// indicare l'intenzione di deallocare
// l'oggetto riferito, non il riferimento!
|
L'uso dei riferimenti per accedere a oggetti dinamici e` sicuramente molto
comodo perche` e` possibile uniformare tali oggetti alle comuni variabili,
tuttavia e` una pratica che bisognerebbe evitare perche` puo` generare
confusione e di conseguenza errori assai insidiosi.
Uso dei reference
I riferimenti sono stati introdotti nel C++
come ulteriore meccanismo di passaggio di parametri (per riferimento).
Una funzione che debba modificare i parametri attuali puo` ora essere
dichiarata in due modi diversi:
|
void Esempio(Tipo* Parametro);
|
oppure in modo del tutto equivalente
|
void Esempio(Tipo& Parametro);
|
Naturalmente cambierebbe il modo in cui chiamare la funzione:
|
long double Var = 0.0;
long double* Ptr = &Var;
// nel primo caso avremmo
Esempio(&Var);
// oppure
Esempio(Ptr);
// nel caso di passaggio per riferimento
Esempio(Var);
// oppure
Esempio(*Ptr);
|
In modo del tutto analogo a quanto visto con i puntatori e` anche possibile
ritornare un riferimento:
|
double& Esempio(float Param1, float Param2) {
/* ... */
double* X = new double;
/* ... */
return *X;
}
|
Puntatori e reference possono essere liberamente scambiati, non esiste
differenza eccetto che non e` necessario dereferenziare un riferimento
e che i riferimenti non possono associati ad un'altra variabile dopo
l'inizializzazione.
Probabilmente vi starete chiedendo che motivo c'era dunque di introdurre
questa caratteristica dato che i puntatori erano gia` sufficienti. Il
problema in effetti non nasce con le funzioni, ma con gli operatori; il C++
consente anche l'overloading degli operatori e
sarebbe spiacevole dover scrivere qualcosa del tipo:
|
&A + &B
|
non si riuscirebbe a capire se si desidera sommare due indirizzi oppure i
due oggetti (che potrebbero essere troppo grossi per passarli per valore).
I riferimenti invece risolvono il problema eliminando ogni possibile
ambiguita` e consentendo una sintassi piu` chiara.
Puntatori vs reference
Visto che per le funzioni e` possibile
scegliere tra puntatori e riferimenti, come decidere quale metodo
scegliere? I riferimenti hanno un vantaggio sui puntatori, dato che nella
chiamata di una funzione non c'e` differenza tra passaggio per valore o per
riferimento, e` possibile cambiare meccanismo senza dover modificare ne` il
codice che chiama la funzione ne` il corpo della funzione stessa. Tuttavia
il meccanismo dei reference nasconde all'utente il fatto che si passa un
indirizzo e non una copia, e cio` puo` creare grossi problemi in fase di
debugging.
Quando e` necessario passare un indirizzo e` quindi meglio usare i
puntatori, che consentono un maggior controllo sugli accessi (tramite la
keyword const) e rendono esplicito il modo in cui il parametro viene
passato. Esiste comunque una eccezione nel caso dei tipi definiti
dall'utente tramite il meccanismo delle classi. In questo caso vedremo che
l'incapsulamento garantisce che l'oggetto passato possa essere modificato
solo da particolari funzioni (funzioni membro e funzioni amiche), e quindi
usare i riferimenti e` piu`conveniente perche` non e` necessario
dereferenziarli, migliorando cosi` la chiarezza del codice; le funzioni membro e le funzioni amiche, in quanto tali, sono invece
autorizzate a modificare l'oggetto e quindi quando vengono usate l'utente
sa gia` che potrebbero esserci effetti laterali.
Non si tratta comunque di una regola generale, come per tante altre cose,
i progettisti del linguaggio hanno pensato di non limitare l'uso
dei costrutti con rigide regole e schemi predefiniti, ma di lasciare al
buon senso del programmatore il compito di decidere quale fosse di volta in
volta la soluzione migliore.
Pagina precedente - Pagina successiva
C++, una panoramica sul linguaggio - seconda edizione © Copyright 1996-1999, Paolo Marotta
|