I puntatori per gestire la memoria del computer
L’argomento dei puntatori spaventa molte persone ed è sicuramente uno dei capitoli più complessi di questo corso. Una volta superato questo scoglio, molte cose ti appariranno più semplici e chiare.
I puntatori sono usati in tutti i programmi C++, anche se non li conoscevi prima. Sono ovunque. Finora erano nascosti e non dovevi gestirli direttamente. A partire da questa sessione, tutto questo cambierà. Impareremo a gestire molto finemente ciò che accade nella memoria del computer.
Questo è un capitolo piuttosto difficile, quindi è normale che tu non capisca tutto al primo tentativo. Non aver paura di leggere questi appunti e di rivedere più volte il video per assicurarti di aver capito!
I puntatori possono essere pensati come maniglie da applicare alle porte delle celle di memoria per poter accedere al loro contenuto sia in lettura che in scrittura, nella pratica una variabile di tipo puntatore contiene l’indirizzo di una locazione di memoria.
short *Puntatore1; Persona *Puntatore3; double **Puntatore2; int UnIntero = 5; int *PuntatoreAInt = &UnIntero;
Il carattere * (asterisco) indica un puntatore, per cui le prime tre righe dichiarano rispettivamente un puntatore a short int, un puntatore a Persona e un puntatore a puntatore a double. La quinta riga dichiara un puntatore a int e ne esegue l’inizializzazione mediante l’operatore & (indirizzo di) che serve ad ottere l’indirizzo della variabile (o di una costante o ancora di una funzione) il cui nome segue l’operatore. Si osservi che un puntatore a un certo tipo può puntare solo a oggetti di quel tipo, (non è possibile ad esempio assegnare l’indirizzo di una variabile di tipo float a un puntatore a char, come mostra il codice seguente), o meglio in molti casi e` possibile farlo, ma viene eseguita una coercizione
Nelle dichiarazioni di puntatori bisogna prestare attenzione a diversi dettagli che possono essere meglio apprezzati tramite esempi:
float *Reale, UnAltroReale; int Intero = 10; const int *Puntatore = &Intero; int *const CostantePuntatore = &Intero; const int *const CostantePuntatoreACostante = &Intero;
La prima dichiarazione contrariamente a quanto si potrebbe pensare non dichiara due puntatori a float, ma un puntatore a float (Reale) e una variabile di tipo float (UnAltroReale): * si applica solo al primo nome che lo segue e quindi il modo corretto di eseguire quelle dichiarazioni era
float *Reale, *UnAltroReale;
E’ possibile dichiarare puntatori generici:
void *PuntatoreGenerico;
I puntatori void possono essere inizializzati come un qualsiasi altro puntatore tipizzato, e a differenza di questi ultimi possono puntare a qualsiasi oggetto senza riguardo al tipo o al fatto che siano costanti, variabili o funzioni; tuttavia non e` possibile eseguire sui puntatori void alcune operazioni definite sui puntatori tipizzati.
Operazioni sui puntatori
Dal punto di vista dell’assegnamento, una variabile di tipo puntatore si comporta esattamente come una variabile di un qualsiasi altro tipo primitivo, basta tener presente che il loro contenuto e` un indirizzo di memoria:
int Pippo = 5, Topolino = 10; char Pluto = 'P'; int *Minnie = &Pippo; int *Basettoni; void *Manetta;
Esempi di assegnamento a puntatori:
Minnie = &Topolino; Manetta = &Minnie; // "Manetta" punta a "Minnie" Basettoni = Minnie;
// “Basettoni” e “Minnie” ora puntano allo stesso oggetto
L’operazione piu` importante che viene eseguita sui puntatori e quella di dereferenziazione o indirezione al fine di ottenere accesso all’oggetto puntato; l’operazione viene eseguita tramite l’operatore di dereferenzazione * posto prefisso al puntatore, come mostra il seguente esempio:
short *P; short int Val = 5; P = &Val; // P punta a Val (cioe` Val e *P sono lo stesso oggetto); cout << "Ora P punta a Val:" << endl; cout << "*P = " << *P << endl; cout << "Val = " << Val << endl << endl; *P = -10; // Modifica l'oggetto puntato da P cout << "Val e` stata modificata tramite P:" << endl; cout << "*P = " << *P << endl; cout << "Val = " << Val << endl << endl; Val = 30; cout << "La modifica su Val si riflette su *P:" << endl; cout << "*P = " << *P << endl; cout << "Val = " << Val << endl << endl;
Il codice appena mostrato fa si` che il puntatore P riferisca alla variabile Val, ed esegue una serie di assegnamenti sia alla variabile che all’oggetto puntato da P mostrandone gli effetti. L’operatore * prefisso ad un puntatore seleziona l’oggetto puntato dal puntatore cosi` che *P utilizzato come operando in una espressione produce l’oggetto puntato da P. Ecco quale sarebbe l’output del precedente frammento di codice se eseguito:
Ora P punta a Val: *P = 5 Val = 5 Val e` stata modificata tramite P: *P = -10 Val = -10 La modifica su Val si riflette su *P: *P = 30 Val = 30
Sui puntatori sono anche definiti gli usuali operatori relazionali:
- < minore di
- > maggiore di
- <= minore o uguale
- >= maggiore o uguale
- == uguale a
- != diverso da
Puntatori vs Array
In C++ le dichiarazioni char Array[] = “Una stringa” e char* Ptr = “Una stringa” hanno lo stesso effetto, entrambe creano una stringa (terminata dal carattere nullo) il cui indirizzo e` posto rispettivamente in Array e in Ptr, e come mostra l’esempio un char* puo` essere utilizzato esattamente come un array di caratteri:
char Array[] = "Una stringa"; char* Ptr = "Una stringa"; // la seguente riga stampa tutte e due le stringhe // si osservi che non e` necessario dereferenziare // un char* (a differenza degli altri tipi di // puntatori) cout << Array << " == " << Ptr << endl; // in questo modo, invece, si stampa solo un carattere: // la dereferenzazione di un char* o l'indicizzazione // di un array causano la visualizzazione di un solo // carattere perche` in effetti si passa all'oggetto // cout non un puntatore a char, ma un oggetto di tipo // char (che cout tratta giustamente in modi diversi) cout << Array[5] << " == " << Ptr[5] << endl; cout << *Ptr << endl;
Un puntatore è più flessibile di quanto non lo sia un array, anche se a costo di un maggiore overhead.
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.
Utilizzando i puntatori invece è possibile realizzare ad esempio una lista il cui numero massimo di elementi non è 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.
Nel caso sia necessario necessario allocare e deallocare interi array, in questi casi si ricorre agli operatori new[] e delete[]: